feat(m365): add certificate auth method to cli (#8404)

Co-authored-by: Sergio Garcia <hello@mistercloudsec.com>
This commit is contained in:
Hugo Pereira Brito
2025-08-11 09:47:56 +02:00
committed by GitHub
parent dcee114ef3
commit 85af4ff77c
11 changed files with 3258 additions and 159 deletions
+1 -1
View File
@@ -2,10 +2,10 @@
All notable changes to the **Prowler SDK** are documented in this file.
## [v5.11.0] (Prowler UNRELEASED)
### Added
- Certificate authentication for M365 provider [(#8404)](https://github.com/prowler-cloud/prowler/pull/8404)
### Changed
+2
View File
@@ -222,6 +222,8 @@ class Provider(ABC):
env_auth=arguments.env_auth,
az_cli_auth=arguments.az_cli_auth,
browser_auth=arguments.browser_auth,
certificate_auth=arguments.certificate_auth,
certificate_path=arguments.certificate_path,
tenant_id=arguments.tenant_id,
init_modules=arguments.init_modules,
fixer_config=fixer_config,
@@ -126,6 +126,18 @@ class M365BaseException(ProwlerException):
"message": "Failed to establish connection to Exchange Online API.",
"remediation": "Ensure the application has proper permission granted to access Exchange Online.",
},
(6030, "M365CertificateCreationError"): {
"message": "Failed to create X.509 certificate object from provided certificate content.",
"remediation": "Ensure the certificate content is valid base64 encoded X.509 certificate data and is properly formatted.",
},
(6031, "M365NotValidCertificateContentError"): {
"message": "The provided certificate content is not valid base64 encoded data.",
"remediation": "Ensure the certificate content is valid base64 encoded X.509 certificate data without line breaks or invalid characters.",
},
(6032, "M365NotValidCertificatePathError"): {
"message": "The provided certificate path is not valid or the file cannot be accessed.",
"remediation": "Ensure the certificate path exists, is accessible, and points to a valid certificate file.",
},
}
def __init__(self, code, file=None, original_exception=None, message=None):
@@ -357,3 +369,24 @@ class M365ExchangeConnectionError(M365CredentialsError):
super().__init__(
6029, file=file, original_exception=original_exception, message=message
)
class M365CertificateCreationError(M365CredentialsError):
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
6030, file=file, original_exception=original_exception, message=message
)
class M365NotValidCertificateContentError(M365CredentialsError):
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
6031, file=file, original_exception=original_exception, message=message
)
class M365NotValidCertificatePathError(M365CredentialsError):
def __init__(self, file=None, original_exception=None, message=None):
super().__init__(
6032, file=file, original_exception=original_exception, message=message
)
@@ -28,6 +28,17 @@ def init_parser(self):
action="store_true",
help="Use Azure interactive browser authentication to log in against Microsoft 365",
)
m365_auth_modes_group.add_argument(
"--certificate-auth",
action="store_true",
help="Use Certificate authentication to log in against Microsoft 365",
)
m365_parser.add_argument(
"--certificate-path",
nargs="?",
default=None,
help="Path to the certificate file to be used with --certificate-auth option",
)
m365_parser.add_argument(
"--tenant-id",
nargs="?",
@@ -4,6 +4,7 @@ import platform
from prowler.lib.logger import logger
from prowler.lib.powershell.powershell import PowerShellSession
from prowler.providers.m365.exceptions.exceptions import (
M365CertificateCreationError,
M365GraphConnectionError,
M365UserCredentialsError,
M365UserNotBelongingToTenantError,
@@ -51,23 +52,71 @@ class M365PowerShell(PowerShellSession):
self.tenant_identity = identity
self.init_credential(credentials)
def clean_certificate_content(self, cert_content: str) -> str:
"""
Clean certificate content for PowerShell consumption.
Removes newlines, carriage returns, and extra spaces from base64 content
to ensure proper parsing in PowerShell.
Args:
cert_content (str): Base64 encoded certificate content
Returns:
str: Cleaned base64 certificate content
"""
# Clean base64 content - remove any newlines or whitespace
clean_content = (
cert_content.strip().replace("\n", "").replace("\r", "").replace(" ", "")
)
logger.info(f"Cleaned certificate content length: {len(clean_content)}")
return clean_content
def init_credential(self, credentials: M365Credentials) -> None:
"""
Initialize PowerShell credential object for Microsoft 365 authentication.
Sanitizes the username and password, then creates a PSCredential object
in the PowerShell session for use with Microsoft 365 cmdlets.
Supports three authentication methods:
1. User authentication (username/password) - Will be deprecated in September 2025
2. Application authentication (client_id/client_secret)
3. Certificate authentication (certificate_content in base64/application_id)
Args:
credentials (M365Credentials): The credentials object containing
username and password.
authentication information.
Note:
The credentials are sanitized to prevent command injection and
stored securely in the PowerShell session.
"""
# Certificate Auth
if credentials.certificate_content and credentials.client_id:
# Clean certificate content for PowerShell consumption
clean_cert_content = self.clean_certificate_content(
credentials.certificate_content
)
# Sanitize credentials
sanitized_client_id = self.sanitize(credentials.client_id)
sanitized_tenant_id = self.sanitize(credentials.tenant_id)
self.execute(
f'$certBytes = [Convert]::FromBase64String("{clean_cert_content}")'
)
error = self.execute(
"$certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(,$certBytes)"
)
if error:
raise M365CertificateCreationError(
f"[{os.path.basename(__file__)}] Error creating certificate: {error}"
)
self.execute(f'$clientID = "{sanitized_client_id}"')
self.execute(f'$tenantID = "{sanitized_tenant_id}"')
self.execute(f'$tenantDomain = "{credentials.tenant_domains[0]}"')
# User Auth (Will be deprecated in September 2025)
if credentials.user and credentials.passwd:
elif credentials.user and credentials.passwd:
credentials.encrypted_passwd = self.encrypt_password(credentials.passwd)
# Sanitize user and password
@@ -135,14 +184,28 @@ class M365PowerShell(PowerShellSession):
"""
Test Microsoft 365 credentials by attempting to authenticate against Entra ID.
Supports testing three authentication methods:
1. User authentication (username/password)
2. Application authentication (client_id/client_secret)
3. Certificate authentication (certificate_content in base64/application_id)
Args:
credentials (M365Credentials): The credentials object containing
username and password to test.
authentication information to test.
Returns:
bool: True if credentials are valid and authentication succeeds, False otherwise.
"""
if credentials.user and credentials.passwd:
# Test Certificate Auth
if credentials.certificate_content and credentials.client_id:
try:
self.test_teams_certificate_connection() or self.test_exchange_certificate_connection()
return True
except Exception as e:
logger.error(f"Exchange Online Certificate connection failed: {e}")
# Test User Auth
elif credentials.user and credentials.passwd:
self.execute(
f'$securePassword = "{credentials.encrypted_passwd}" | ConvertTo-SecureString' # encrypted password already sanitized
)
@@ -161,6 +224,7 @@ class M365PowerShell(PowerShellSession):
)
# Validate credentials
# Test Exchange Online connection
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
@@ -168,21 +232,19 @@ class M365PowerShell(PowerShellSession):
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}",
)
# Test Microsoft Teams connection
result = self.execute("Connect-MicrosoftTeams -Credential $credential")
if self.tenant_identity.user 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 M365UserCredentialsError(
file=os.path.basename(__file__),
message=f"Error connecting to PowerShell modules: {result if result else 'Unknown error'}",
)
return True
@@ -235,6 +297,9 @@ class M365PowerShell(PowerShellSession):
"Microsoft Teams connection failed: Please check your permissions and try again."
)
return False
self.execute(
'Connect-MicrosoftTeams -AccessTokens @("$graphToken","$teamsToken")'
)
return True
except Exception as e:
logger.error(
@@ -242,6 +307,31 @@ class M365PowerShell(PowerShellSession):
)
return False
def test_teams_certificate_connection(self) -> bool:
"""Test Microsoft Teams API connection using certificate and raise exception if it fails."""
result = self.execute(
"Connect-MicrosoftTeams -Certificate $certificate -ApplicationId $clientID -TenantId $tenantID"
)
if self.tenant_identity.identity_id not in result:
logger.error(f"Microsoft Teams Certificate connection failed: {result}")
return False
return True
def test_teams_user_connection(self) -> bool:
"""Test Microsoft Teams API connection using user authentication and raise exception if it fails."""
result = self.execute("Connect-MicrosoftTeams -Credential $credential")
if self.tenant_identity.user not in result:
logger.error(f"Microsoft Teams User Auth connection failed: {result}.")
return False
connection = self.execute("Get-CsTeamsClientConfiguration")
if not connection:
logger.error(
"Microsoft Teams User Auth connection failed: Please check your permissions and try again."
)
return False
return True
def test_exchange_connection(self) -> bool:
"""Test Exchange Online API connection and raise exception if it fails."""
try:
@@ -258,6 +348,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"'
)
return True
except Exception as e:
logger.error(
@@ -265,11 +358,40 @@ class M365PowerShell(PowerShellSession):
)
return False
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"
)
if "https://aka.ms/exov3-module" not in result:
logger.error(f"Exchange Online Certificate connection failed: {result}")
return False
return True
def test_exchange_user_connection(self) -> bool:
"""Test Exchange Online API connection using user authentication and raise exception if it fails."""
result = self.execute("Connect-ExchangeOnline -Credential $credential")
if "https://aka.ms/exov3-module" not in result:
logger.error(f"Exchange Online User Auth connection failed: {result}.")
return False
connection = self.execute("Get-OrganizationConfig")
if not connection:
logger.error(
"Exchange Online User Auth connection failed: Please check your permissions and try again."
)
return False
return True
def connect_microsoft_teams(self) -> dict:
"""
Connect to Microsoft Teams Module PowerShell Module.
Establishes a connection to Microsoft Teams using the initialized credentials.
Supports three authentication methods:
1. User authentication (username/password)
2. Application authentication (client_id/client_secret)
3. Certificate authentication (certificate_content in base64/application_id)
Returns:
dict: Connection status information in JSON format.
@@ -277,26 +399,15 @@ class M365PowerShell(PowerShellSession):
Note:
This method requires the Microsoft Teams PowerShell module to be installed.
"""
# Certificate Auth
if self.execute("Write-Output $certificate") != "":
return self.test_teams_certificate_connection()
# 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 User Auth connection failed: Please check your permissions and try again."
)
return connection
return self.test_teams_user_connection()
# Application Auth
else:
connection = self.test_teams_connection()
if connection:
self.execute(
'Connect-MicrosoftTeams -AccessTokens @("$graphToken","$teamsToken")'
)
return connection
return self.test_teams_connection()
def get_teams_settings(self) -> dict:
"""
@@ -383,6 +494,10 @@ class M365PowerShell(PowerShellSession):
Connect to Exchange Online PowerShell Module.
Establishes a connection to Exchange Online using the initialized credentials.
Supports three authentication methods:
1. User authentication (username/password)
2. Application authentication (client_id/client_secret)
3. Certificate authentication (certificate_content in base64/application_id)
Returns:
dict: Connection status information in JSON format.
@@ -390,25 +505,15 @@ class M365PowerShell(PowerShellSession):
Note:
This method requires the Exchange Online PowerShell module to be installed.
"""
# Certificate Auth
if self.execute("Write-Output $certificate") != "":
return self.test_exchange_certificate_connection()
# 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 User Auth connection failed: Please check your permissions and try again."
)
return False
return self.test_exchange_user_connection()
# Application Auth
else:
connection = self.test_exchange_connection()
if connection:
self.execute(
'Connect-ExchangeOnline -AccessToken $exchangeToken.AccessToken -Organization "$tenantID"'
)
return connection
return self.test_exchange_connection()
def get_audit_log_config(self) -> dict:
"""
+306 -49
View File
@@ -1,4 +1,5 @@
import asyncio
import base64
import os
from argparse import ArgumentTypeError
from os import getenv
@@ -6,6 +7,7 @@ from uuid import UUID
from azure.core.exceptions import ClientAuthenticationError, HttpResponseError
from azure.identity import (
CertificateCredential,
ClientSecretCredential,
CredentialUnavailableError,
DefaultAzureCredential,
@@ -41,6 +43,8 @@ from prowler.providers.m365.exceptions.exceptions import (
M365MissingEnvironmentCredentialsError,
M365NoAuthenticationMethodError,
M365NotTenantIdButClientIdAndClientSecretError,
M365NotValidCertificateContentError,
M365NotValidCertificatePathError,
M365NotValidClientIdError,
M365NotValidClientSecretError,
M365NotValidTenantIdError,
@@ -111,11 +115,14 @@ class M365Provider(Provider):
env_auth: bool = False,
az_cli_auth: bool = False,
browser_auth: bool = False,
certificate_auth: bool = False,
tenant_id: str = None,
client_id: str = None,
client_secret: str = None,
user: str = None,
password: str = None,
certificate_content: str = None,
certificate_path: str = None,
init_modules: bool = False,
region: str = "M365Global",
config_content: dict = None,
@@ -158,11 +165,14 @@ class M365Provider(Provider):
sp_env_auth,
env_auth,
browser_auth,
certificate_auth,
tenant_id,
client_id,
client_secret,
user,
password,
certificate_content,
certificate_path,
)
logger.info("Checking if region is different than default one")
@@ -170,13 +180,15 @@ class M365Provider(Provider):
# Get the dict from the static credentials
m365_credentials = None
if tenant_id and client_id and client_secret:
if tenant_id and client_id:
m365_credentials = self.validate_static_credentials(
tenant_id=tenant_id,
client_id=client_id,
client_secret=client_secret,
user=user,
password=password,
certificate_content=certificate_content,
certificate_path=certificate_path,
)
# Set up the M365 session
@@ -185,6 +197,8 @@ class M365Provider(Provider):
sp_env_auth,
env_auth,
browser_auth,
certificate_auth,
certificate_path,
tenant_id,
m365_credentials,
self._region_config,
@@ -196,6 +210,7 @@ class M365Provider(Provider):
env_auth,
browser_auth,
az_cli_auth,
certificate_auth,
self._session,
)
@@ -203,6 +218,8 @@ class M365Provider(Provider):
self._credentials = self.setup_powershell(
env_auth=env_auth,
sp_env_auth=sp_env_auth,
certificate_auth=certificate_auth,
certificate_path=certificate_path,
m365_credentials=m365_credentials,
identity=self.identity,
init_modules=init_modules,
@@ -279,11 +296,14 @@ class M365Provider(Provider):
sp_env_auth: bool,
env_auth: bool,
browser_auth: bool,
certificate_auth: bool,
tenant_id: str,
client_id: str,
client_secret: str,
user: str,
password: str,
certificate_content: str,
certificate_path: str,
):
"""
Validates the authentication arguments for the M365 provider.
@@ -293,11 +313,14 @@ class M365Provider(Provider):
sp_env_auth (bool): Flag indicating whether application authentication with environment variables is enabled.
env_auth: (bool): Flag indicating whether to use application and PowerShell authentication with environment variables.
browser_auth (bool): Flag indicating whether browser authentication is enabled.
certificate_auth (bool): Flag indicating whether certificate authentication is enabled.
tenant_id (str): The M365 Tenant ID.
client_id (str): The M365 Client ID.
client_secret (str): The M365 Client Secret.
user (str): The M365 User Account.
password (str): The M365 User Password.
certificate_content (str): The M365 Certificate Content.
certificate_path (str): The path to the certificate file.
Raises:
M365BrowserAuthNoTenantIDError: If browser authentication is enabled but the tenant ID is not found.
@@ -314,28 +337,33 @@ class M365Provider(Provider):
and not sp_env_auth
and not browser_auth
and not env_auth
and not certificate_auth
):
raise M365NoAuthenticationMethodError(
file=os.path.basename(__file__),
message="M365 provider requires at least one authentication method set: [--env-auth | --az-cli-auth | --sp-env-auth | --browser-auth]",
message="M365 provider requires at least one authentication method set: [--env-auth | --az-cli-auth | --sp-env-auth | --browser-auth | --certificate-auth]",
)
elif browser_auth and not tenant_id:
raise M365BrowserAuthNoTenantIDError(
file=os.path.basename(__file__),
message="M365 Tenant ID (--tenant-id) is required for browser authentication mode",
)
elif env_auth:
if not user or not password or not tenant_id:
raise M365MissingEnvironmentCredentialsError(
file=os.path.basename(__file__),
message="M365 provider requires AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID, M365_USER and M365_PASSWORD environment variables to be set when using --env-auth",
)
else:
if not tenant_id:
raise M365NotTenantIdButClientIdAndClientSecretError(
file=os.path.basename(__file__),
message="Tenant Id is required for M365 static credentials. Make sure you are using the correct credentials.",
)
if (
not certificate_content
and not certificate_path
and not (user and password)
and not client_secret
):
raise M365ConfigCredentialsError(
file=os.path.basename(__file__),
message="You must provide a valid set of credentials. Please check your credentials and try again.",
)
@staticmethod
def setup_region_config(region):
@@ -378,6 +406,8 @@ class M365Provider(Provider):
def setup_powershell(
env_auth: bool = False,
sp_env_auth: bool = False,
certificate_auth: bool = False,
certificate_path: str = None,
m365_credentials: dict = {},
identity: M365IdentityInfo = None,
init_modules: bool = False,
@@ -402,6 +432,7 @@ class M365Provider(Provider):
client_id=m365_credentials.get("client_id", ""),
client_secret=m365_credentials.get("client_secret", ""),
tenant_id=m365_credentials.get("tenant_id", ""),
certificate_content=m365_credentials.get("certificate_content", ""),
tenant_domains=identity.tenant_domains,
)
elif env_auth:
@@ -440,10 +471,28 @@ class M365Provider(Provider):
tenant_domains=identity.tenant_domains,
)
elif certificate_auth:
client_id = getenv("AZURE_CLIENT_ID")
tenant_id = getenv("AZURE_TENANT_ID")
if certificate_path:
with open(certificate_path, "rb") as cert_file:
# Encode the certificate content to base64 since PowerShell expects a base64 string
certificate_content = base64.b64encode(cert_file.read())
else:
certificate_content = getenv("M365_CERTIFICATE_CONTENT")
credentials = M365Credentials(
client_id=client_id,
tenant_id=tenant_id,
certificate_content=certificate_content,
tenant_domains=identity.tenant_domains,
)
if credentials:
if identity and credentials.user:
identity.user = credentials.user
identity.identity_type = "Service Principal and User Credentials"
if identity and credentials.certificate_content:
identity.identity_type = "Service Principal with Certificate"
test_session = M365PowerShell(credentials, identity)
try:
if init_modules:
@@ -478,6 +527,10 @@ class M365Provider(Provider):
report_lines.append(
f"M365 User: {Fore.YELLOW}{self.credentials.user}{Style.RESET_ALL}"
)
elif self.credentials and self.credentials.certificate_content:
report_lines.append(
f"M365 Certificate Thumbprint: {Fore.YELLOW}{self._identity.certificate_thumbprint}{Style.RESET_ALL}"
)
report_title = (
f"{Style.BRIGHT}Using the M365 credentials below:{Style.RESET_ALL}"
)
@@ -491,6 +544,8 @@ class M365Provider(Provider):
sp_env_auth: bool,
env_auth: bool,
browser_auth: bool,
certificate_auth: bool,
certificate_path: str,
tenant_id: str,
m365_credentials: dict,
region_config: M365RegionConfig,
@@ -510,6 +565,8 @@ class M365Provider(Provider):
- client_secret: The M365 client secret
- user: The M365 user email
- password: The M365 user password
- certificate_content: The M365 certificate content
- certificate_path: The path to the certificate file.
- provider_id: The M365 provider ID (in this case the Tenant ID).
region_config (M365RegionConfig): The region configuration object.
@@ -530,16 +587,45 @@ class M365Provider(Provider):
f"{environment_credentials_error.__class__.__name__}[{environment_credentials_error.__traceback__.tb_lineno}] -- {environment_credentials_error}"
)
raise environment_credentials_error
elif certificate_auth:
try:
M365Provider.check_certificate_creds_env_vars(
check_certificate_content=not certificate_path
)
except M365EnvironmentVariableError as environment_variable_error:
logger.critical(
f"{environment_variable_error.__class__.__name__}[{environment_variable_error.__traceback__.tb_lineno}] -- {environment_variable_error}"
)
raise environment_variable_error
try:
if m365_credentials:
try:
credentials = ClientSecretCredential(
tenant_id=m365_credentials["tenant_id"],
client_id=m365_credentials["client_id"],
client_secret=m365_credentials["client_secret"],
user=m365_credentials["user"],
password=m365_credentials["password"],
)
if m365_credentials["certificate_content"]:
credentials = CertificateCredential(
tenant_id=m365_credentials["tenant_id"],
client_id=m365_credentials["client_id"],
certificate_data=base64.b64decode(
m365_credentials["certificate_content"]
),
)
elif m365_credentials["certificate_path"]:
with open(
m365_credentials["certificate_path"], "rb"
) as cert_file:
certificate_data = cert_file.read()
credentials = CertificateCredential(
tenant_id=m365_credentials["tenant_id"],
client_id=m365_credentials["client_id"],
certificate_data=certificate_data,
)
else:
credentials = ClientSecretCredential(
tenant_id=m365_credentials["tenant_id"],
client_id=m365_credentials["client_id"],
client_secret=m365_credentials["client_secret"],
user=m365_credentials["user"],
password=m365_credentials["password"],
)
return credentials
except ClientAuthenticationError as error:
logger.error(
@@ -562,13 +648,34 @@ class M365Provider(Provider):
raise M365ConfigCredentialsError(
file=os.path.basename(__file__), original_exception=error
)
elif certificate_auth:
try:
if certificate_path:
with open(certificate_path, "rb") as cert_file:
certificate_data = cert_file.read()
else:
certificate_data = base64.b64decode(
getenv("M365_CERTIFICATE_CONTENT")
)
credentials = CertificateCredential(
tenant_id=getenv("AZURE_TENANT_ID"),
client_id=getenv("AZURE_CLIENT_ID"),
certificate_data=certificate_data,
)
except ClientAuthenticationError as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}] -- {error}"
)
raise M365ClientAuthenticationError(
file=os.path.basename(__file__), original_exception=error
)
else:
# Since the authentication method to be used will come as True, we have to negate it since
# DefaultAzureCredential sets just one authentication method, excluding the others
try:
credentials = DefaultAzureCredential(
exclude_environment_credential=not (
sp_env_auth or env_auth
sp_env_auth or env_auth or certificate_auth
),
exclude_cli_credential=not az_cli_auth,
# M365 Auth using Managed Identity is not supported
@@ -633,6 +740,7 @@ class M365Provider(Provider):
sp_env_auth: bool = False,
env_auth: bool = False,
browser_auth: bool = False,
certificate_auth: bool = False,
tenant_id: str = None,
region: str = "M365Global",
raise_on_exception: bool = True,
@@ -640,6 +748,8 @@ class M365Provider(Provider):
client_secret: str = None,
user: str = None,
password: str = None,
certificate_content: str = None,
certificate_path: str = None,
provider_id: str = None,
) -> Connection:
"""Test connection to M365 tenant and PowerShell modules.
@@ -652,6 +762,7 @@ class M365Provider(Provider):
sp_env_auth (bool): Flag indicating whether to use application authentication with environment variables.
env_auth: (bool): Flag indicating whether to use application and PowerShell authentication with environment variables.
browser_auth (bool): Flag indicating whether to use interactive browser authentication.
certificate_auth (bool): Flag indicating whether to use certificate authentication.
tenant_id (str): The M365 Active Directory tenant ID.
region (str): The M365 region.
raise_on_exception (bool): Flag indicating whether to raise an exception if the connection fails.
@@ -688,11 +799,14 @@ class M365Provider(Provider):
sp_env_auth,
env_auth,
browser_auth,
certificate_auth,
tenant_id,
client_id,
client_secret,
user,
password,
certificate_content,
certificate_path,
)
region_config = M365Provider.setup_region_config(region)
@@ -704,8 +818,6 @@ class M365Provider(Provider):
tenant_id=tenant_id,
client_id=client_id,
client_secret=client_secret,
user=None,
password=None,
)
else:
m365_credentials = M365Provider.validate_static_credentials(
@@ -715,6 +827,12 @@ class M365Provider(Provider):
user=user,
password=password,
)
elif tenant_id and client_id and certificate_content:
m365_credentials = M365Provider.validate_static_credentials(
tenant_id=tenant_id,
client_id=client_id,
certificate_content=certificate_content,
)
# Set up the M365 session
session = M365Provider.setup_session(
@@ -722,6 +840,8 @@ class M365Provider(Provider):
sp_env_auth,
env_auth,
browser_auth,
certificate_auth,
certificate_path,
tenant_id,
m365_credentials,
region_config,
@@ -737,6 +857,7 @@ class M365Provider(Provider):
env_auth,
browser_auth,
az_cli_auth,
certificate_auth,
session,
)
@@ -758,6 +879,8 @@ class M365Provider(Provider):
M365Provider.setup_powershell(
env_auth,
sp_env_auth,
certificate_auth,
certificate_path,
m365_credentials,
identity,
)
@@ -889,12 +1012,44 @@ class M365Provider(Provider):
message=f"Missing environment variable {env_var} required to authenticate.",
)
@staticmethod
def check_certificate_creds_env_vars(check_certificate_content: bool):
"""
Checks the presence of required environment variables for service principal authentication against Azure.
This method checks for the presence of the following environment variables:
- AZURE_CLIENT_ID: Azure client ID
- AZURE_TENANT_ID: Azure tenant ID
- M365_CERTIFICATE_CONTENT: Azure certificate content
If any of the environment variables is missing, it logs a critical error and exits the program.
"""
logger.info(
"M365 provider: checking service principal environment variables ..."
)
env_vars = [
"AZURE_CLIENT_ID",
"AZURE_TENANT_ID",
]
if check_certificate_content:
env_vars.append("M365_CERTIFICATE_CONTENT")
for env_var in env_vars:
if not getenv(env_var):
logger.critical(
f"M365 provider: Missing environment variable {env_var} needed to authenticate against M365."
)
raise M365EnvironmentVariableError(
file=os.path.basename(__file__),
message=f"Missing environment variable {env_var} required to authenticate.",
)
@staticmethod
def setup_identity(
sp_env_auth,
env_auth,
browser_auth,
az_cli_auth,
certificate_auth,
session,
):
"""
@@ -968,6 +1123,16 @@ class M365Provider(Provider):
or session.credentials[0]._credential.client_id
or "Unknown user id (Missing AAD permissions)"
)
elif certificate_auth:
identity.identity_type = "Service Principal with Certificate"
identity.identity_id = (
getenv("AZURE_CLIENT_ID")
or session.credentials[0]._credential.client_id
or "Unknown user id (Missing AAD permissions)"
)
identity.certificate_thumbprint = session._client_credential.get(
"thumbprint", "Unknown certificate thumbprint"
)
elif browser_auth or az_cli_auth:
identity.identity_type = "User"
try:
@@ -987,8 +1152,14 @@ class M365Provider(Provider):
)
else:
# Static Credentials
identity.identity_type = "Service Principal"
identity.identity_id = session._client_id
if isinstance(session, CertificateCredential):
identity.identity_type = "Service Principal with Certificate"
identity.certificate_thumbprint = session._client_credential.get(
"thumbprint", "Unknown certificate thumbprint"
)
else:
identity.identity_type = "Service Principal"
# Retrieve tenant id from the client
client = GraphServiceClient(credentials=session)
@@ -1005,6 +1176,8 @@ class M365Provider(Provider):
client_secret: str = None,
user: str = None,
password: str = None,
certificate_content: str = None,
certificate_path: str = None,
) -> dict:
"""
Validates the static credentials for the M365 provider.
@@ -1015,6 +1188,8 @@ class M365Provider(Provider):
client_secret (str): The M365 client secret.
user (str): The M365 user email.
password (str): The M365 user password.
certificate_content (str): The M365 Certificate Content.
certificate_path (str): The path to the certificate file.
Raises:
M365NotValidTenantIdError: If the provided M365 Tenant ID is not valid.
@@ -1045,21 +1220,47 @@ class M365Provider(Provider):
message="The provided Client ID is not valid.",
)
# Validate the Client Secret
if not client_secret:
if not certificate_content and not certificate_path and not client_secret:
raise M365NotValidClientSecretError(
file=os.path.basename(__file__),
message="The provided Client Secret is not valid.",
message="You must provide a client secret, certificate content or certificate path. Please check your credentials and try again.",
)
if certificate_content:
try:
# Validate that certificate content can be properly decoded from base64
base64.b64decode(certificate_content)
except Exception as e:
raise M365NotValidCertificateContentError(
file=os.path.basename(__file__),
message=f"The provided certificate content is not valid base64 encoded data: {str(e)}",
)
if certificate_path:
try:
with open(certificate_path, "rb") as cert_file:
certificate_content = cert_file.read()
except Exception as e:
raise M365NotValidCertificatePathError(
file=os.path.basename(__file__),
message=f"The provided certificate path is not valid: {str(e)}",
)
try:
M365Provider.verify_client(tenant_id, client_id, client_secret)
M365Provider.verify_client(
tenant_id,
client_id,
client_secret,
certificate_content,
certificate_path,
)
return {
"tenant_id": tenant_id,
"client_id": client_id,
"client_secret": client_secret,
"user": user,
"password": password,
"certificate_content": certificate_content,
"certificate_path": certificate_path,
}
except M365NotValidTenantIdError as tenant_id_error:
logger.error(
@@ -1087,7 +1288,9 @@ class M365Provider(Provider):
)
@staticmethod
def verify_client(tenant_id, client_id, client_secret) -> None:
def verify_client(
tenant_id, client_id, client_secret, certificate_content, certificate_path
) -> None:
"""
Verifies the M365 client credentials using the specified tenant ID, client ID, and client secret.
@@ -1095,52 +1298,106 @@ class M365Provider(Provider):
tenant_id (str): The M365 Active Directory tenant ID.
client_id (str): The M365 client ID.
client_secret (str): The M365 client secret.
certificate_content (str): The M365 certificate content.
certificate_path (str): The path to the certificate file.
Raises:
M365NotValidTenantIdError: If the provided M365 Tenant ID is not valid.
M365NotValidClientIdError: If the provided M365 Client ID is not valid.
M365NotValidClientSecretError: If the provided M365 Client Secret is not valid.
M365NotValidCertificateContentError: If the provided M365 Certificate Content is not valid.
M365NotValidCertificatePathError: If the provided M365 Certificate Path is not valid.
Returns:
None
"""
authority = f"https://login.microsoftonline.com/{tenant_id}"
try:
# Create a ConfidentialClientApplication instance
app = ConfidentialClientApplication(
client_id=client_id,
client_credential=client_secret,
authority=authority,
)
if client_secret:
# Create a ConfidentialClientApplication instance
app = ConfidentialClientApplication(
client_id=client_id,
client_credential=client_secret,
authority=authority,
)
# Attempt to acquire a token
result = app.acquire_token_for_client(
scopes=["https://graph.microsoft.com/.default"]
)
# Attempt to acquire a token
result = app.acquire_token_for_client(
scopes=["https://graph.microsoft.com/.default"]
)
# Check if token acquisition was successful
if "access_token" not in result:
# Handle specific errors based on the MSAL response
error_description = result.get("error_description", "")
if f"Tenant '{tenant_id}'" in error_description:
raise M365NotValidTenantIdError(
file=os.path.basename(__file__),
message="The provided Tenant ID is not valid for the specified Client ID and Client Secret.",
)
if (
f"Application with identifier '{client_id}'"
in error_description
):
raise M365NotValidClientIdError(
file=os.path.basename(__file__),
message="The provided Client ID is not valid for the specified Tenant ID and Client Secret.",
)
if "Invalid client secret provided" in error_description:
raise M365NotValidClientSecretError(
file=os.path.basename(__file__),
message="The provided Client Secret is not valid for the specified Tenant ID and Client ID.",
)
elif certificate_content:
credential = CertificateCredential(
client_id=client_id,
tenant_id=tenant_id,
certificate_data=base64.b64decode(certificate_content),
)
client = GraphServiceClient(credentials=credential)
# Check if token acquisition was successful
if "access_token" not in result:
# Handle specific errors based on the MSAL response
error_description = result.get("error_description", "")
if f"Tenant '{tenant_id}'" in error_description:
raise M365NotValidTenantIdError(
# Verify that the certificate is valid
async def verify_certificate():
result = await client.domains.get()
return result.value
result = asyncio.get_event_loop().run_until_complete(
verify_certificate()
)
if not result:
raise M365NotValidCertificateContentError(
file=os.path.basename(__file__),
message="The provided Tenant ID is not valid for the specified Client ID and Client Secret.",
message="The provided certificate content is not valid.",
)
if f"Application with identifier '{client_id}'" in error_description:
raise M365NotValidClientIdError(
elif certificate_path:
with open(certificate_path, "rb") as cert_file:
certificate_content = cert_file.read()
credential = CertificateCredential(
client_id=client_id,
tenant_id=tenant_id,
certificate_data=certificate_content,
)
client = GraphServiceClient(credentials=credential)
# Verify that the certificate is valid
async def verify_certificate():
result = await client.domains.get()
return result.value
result = asyncio.get_event_loop().run_until_complete(
verify_certificate()
)
if not result:
raise M365NotValidCertificatePathError(
file=os.path.basename(__file__),
message="The provided Client ID is not valid for the specified Tenant ID and Client Secret.",
)
if "Invalid client secret provided" in error_description:
raise M365NotValidClientSecretError(
file=os.path.basename(__file__),
message="The provided Client Secret is not valid for the specified Tenant ID and Client ID.",
message="The provided certificate is not valid.",
)
except (
M365NotValidTenantIdError,
M365NotValidClientIdError,
M365NotValidClientSecretError,
M365NotValidCertificateContentError,
M365NotValidCertificatePathError,
) as m365_error:
# M365 specific errors already raised
raise RuntimeError(f"{m365_error}")
+3 -1
View File
@@ -11,6 +11,7 @@ class M365IdentityInfo(BaseModel):
identity_type: str = ""
tenant_id: str = ""
tenant_domain: str = "Unknown tenant domain (missing Entra permissions)"
certificate_thumbprint: str = ""
tenant_domains: list[str] = []
location: str = ""
user: str = None
@@ -28,9 +29,10 @@ class M365Credentials(BaseModel):
passwd: Optional[str] = None
encrypted_passwd: Optional[str] = None
client_id: str = ""
client_secret: str = ""
client_secret: Optional[str] = None
tenant_id: str = ""
tenant_domains: list[str] = []
certificate_content: Optional[str] = None
class M365OutputOptions(ProviderOutputOptions):
@@ -0,0 +1,641 @@
import argparse
from unittest.mock import MagicMock
from prowler.providers.m365.lib.arguments import arguments
class TestM365Arguments:
def setup_method(self):
"""Setup mock ArgumentParser for testing"""
self.mock_parser = MagicMock()
self.mock_subparsers = MagicMock()
self.mock_m365_parser = MagicMock()
self.mock_auth_group = MagicMock()
self.mock_auth_modes_group = MagicMock()
self.mock_regions_group = MagicMock()
# Setup the mock chain
self.mock_parser.add_subparsers.return_value = self.mock_subparsers
self.mock_subparsers.add_parser.return_value = self.mock_m365_parser
self.mock_m365_parser.add_argument_group.side_effect = [
self.mock_auth_group,
self.mock_regions_group,
]
self.mock_auth_group.add_mutually_exclusive_group.return_value = (
self.mock_auth_modes_group
)
def test_init_parser_creates_subparser(self):
"""Test that init_parser creates the M365 subparser correctly"""
# Create a mock object that has the necessary attributes
mock_m365_args = MagicMock()
mock_m365_args.subparsers = self.mock_subparsers
mock_m365_args.common_providers_parser = MagicMock()
# Call init_parser
arguments.init_parser(mock_m365_args)
# Verify subparser was created
self.mock_subparsers.add_parser.assert_called_once_with(
"m365",
parents=[mock_m365_args.common_providers_parser],
help="M365 Provider",
)
def test_init_parser_creates_argument_groups(self):
"""Test that init_parser creates the correct argument groups"""
mock_m365_args = MagicMock()
mock_m365_args.subparsers = self.mock_subparsers
mock_m365_args.common_providers_parser = MagicMock()
arguments.init_parser(mock_m365_args)
# Verify argument groups were created
assert self.mock_m365_parser.add_argument_group.call_count == 2
calls = self.mock_m365_parser.add_argument_group.call_args_list
assert calls[0][0][0] == "Authentication Modes"
assert calls[1][0][0] == "Regions"
def test_init_parser_creates_mutually_exclusive_auth_group(self):
"""Test that init_parser creates mutually exclusive authentication group"""
mock_m365_args = MagicMock()
mock_m365_args.subparsers = self.mock_subparsers
mock_m365_args.common_providers_parser = MagicMock()
arguments.init_parser(mock_m365_args)
# Verify mutually exclusive group was created for authentication modes
self.mock_auth_group.add_mutually_exclusive_group.assert_called_once()
def test_init_parser_adds_authentication_arguments(self):
"""Test that init_parser adds all authentication arguments"""
mock_m365_args = MagicMock()
mock_m365_args.subparsers = self.mock_subparsers
mock_m365_args.common_providers_parser = MagicMock()
arguments.init_parser(mock_m365_args)
# Verify authentication arguments were added to the mutually exclusive group
assert self.mock_auth_modes_group.add_argument.call_count == 5
# Check that all authentication arguments are present
calls = self.mock_auth_modes_group.add_argument.call_args_list
auth_args = [call[0][0] for call in calls]
assert "--az-cli-auth" in auth_args
assert "--env-auth" in auth_args
assert "--sp-env-auth" in auth_args
assert "--browser-auth" in auth_args
assert "--certificate-auth" in auth_args
def test_init_parser_adds_non_exclusive_arguments(self):
"""Test that init_parser adds non-exclusive arguments directly to parser"""
mock_m365_args = MagicMock()
mock_m365_args.subparsers = self.mock_subparsers
mock_m365_args.common_providers_parser = MagicMock()
arguments.init_parser(mock_m365_args)
# Verify non-exclusive arguments were added to main parser
assert self.mock_m365_parser.add_argument.call_count == 3
# Check that non-exclusive arguments are present
calls = self.mock_m365_parser.add_argument.call_args_list
non_exclusive_args = [call[0][0] for call in calls]
assert "--tenant-id" in non_exclusive_args
assert "--init-modules" in non_exclusive_args
assert "--certificate-path" in non_exclusive_args
def test_init_parser_adds_region_arguments(self):
"""Test that init_parser adds region arguments"""
mock_m365_args = MagicMock()
mock_m365_args.subparsers = self.mock_subparsers
mock_m365_args.common_providers_parser = MagicMock()
arguments.init_parser(mock_m365_args)
# Verify region arguments were added to regions group
assert self.mock_regions_group.add_argument.call_count == 1
# Check that region argument is present
calls = self.mock_regions_group.add_argument.call_args_list
region_args = [call[0][0] for call in calls]
assert "--region" in region_args
def test_az_cli_auth_argument_configuration(self):
"""Test that az-cli-auth argument is configured correctly"""
mock_m365_args = MagicMock()
mock_m365_args.subparsers = self.mock_subparsers
mock_m365_args.common_providers_parser = MagicMock()
arguments.init_parser(mock_m365_args)
# Find the az-cli-auth argument call
calls = self.mock_auth_modes_group.add_argument.call_args_list
az_cli_call = None
for call in calls:
if call[0][0] == "--az-cli-auth":
az_cli_call = call
break
assert az_cli_call is not None
# Check argument configuration
kwargs = az_cli_call[1]
assert kwargs["action"] == "store_true"
assert "Azure CLI authentication" in kwargs["help"]
def test_env_auth_argument_configuration(self):
"""Test that env-auth argument is configured correctly"""
mock_m365_args = MagicMock()
mock_m365_args.subparsers = self.mock_subparsers
mock_m365_args.common_providers_parser = MagicMock()
arguments.init_parser(mock_m365_args)
# Find the env-auth argument call
calls = self.mock_auth_modes_group.add_argument.call_args_list
env_auth_call = None
for call in calls:
if call[0][0] == "--env-auth":
env_auth_call = call
break
assert env_auth_call is not None
# Check argument configuration
kwargs = env_auth_call[1]
assert kwargs["action"] == "store_true"
assert "User and Password environment variables" in kwargs["help"]
def test_sp_env_auth_argument_configuration(self):
"""Test that sp-env-auth argument is configured correctly"""
mock_m365_args = MagicMock()
mock_m365_args.subparsers = self.mock_subparsers
mock_m365_args.common_providers_parser = MagicMock()
arguments.init_parser(mock_m365_args)
# Find the sp-env-auth argument call
calls = self.mock_auth_modes_group.add_argument.call_args_list
sp_env_call = None
for call in calls:
if call[0][0] == "--sp-env-auth":
sp_env_call = call
break
assert sp_env_call is not None
# Check argument configuration
kwargs = sp_env_call[1]
assert kwargs["action"] == "store_true"
assert "Azure Service Principal environment variables" in kwargs["help"]
def test_browser_auth_argument_configuration(self):
"""Test that browser-auth argument is configured correctly"""
mock_m365_args = MagicMock()
mock_m365_args.subparsers = self.mock_subparsers
mock_m365_args.common_providers_parser = MagicMock()
arguments.init_parser(mock_m365_args)
# Find the browser-auth argument call
calls = self.mock_auth_modes_group.add_argument.call_args_list
browser_auth_call = None
for call in calls:
if call[0][0] == "--browser-auth":
browser_auth_call = call
break
assert browser_auth_call is not None
# Check argument configuration
kwargs = browser_auth_call[1]
assert kwargs["action"] == "store_true"
assert "Azure interactive browser authentication" in kwargs["help"]
def test_certificate_auth_argument_configuration(self):
"""Test that certificate-auth argument is configured correctly"""
mock_m365_args = MagicMock()
mock_m365_args.subparsers = self.mock_subparsers
mock_m365_args.common_providers_parser = MagicMock()
arguments.init_parser(mock_m365_args)
# Find the certificate-auth argument call
calls = self.mock_auth_modes_group.add_argument.call_args_list
cert_auth_call = None
for call in calls:
if call[0][0] == "--certificate-auth":
cert_auth_call = call
break
assert cert_auth_call is not None
# Check argument configuration
kwargs = cert_auth_call[1]
assert kwargs["action"] == "store_true"
assert "Certificate authentication" in kwargs["help"]
def test_tenant_id_argument_configuration(self):
"""Test that tenant-id argument is configured correctly"""
mock_m365_args = MagicMock()
mock_m365_args.subparsers = self.mock_subparsers
mock_m365_args.common_providers_parser = MagicMock()
arguments.init_parser(mock_m365_args)
# Find the tenant-id argument call
calls = self.mock_m365_parser.add_argument.call_args_list
tenant_id_call = None
for call in calls:
if call[0][0] == "--tenant-id":
tenant_id_call = call
break
assert tenant_id_call is not None
# Check argument configuration
kwargs = tenant_id_call[1]
assert kwargs["nargs"] == "?"
assert kwargs["default"] is None
assert "Microsoft 365 Tenant ID" in kwargs["help"]
assert "--browser-auth" in kwargs["help"]
def test_init_modules_argument_configuration(self):
"""Test that init-modules argument is configured correctly"""
mock_m365_args = MagicMock()
mock_m365_args.subparsers = self.mock_subparsers
mock_m365_args.common_providers_parser = MagicMock()
arguments.init_parser(mock_m365_args)
# Find the init-modules argument call
calls = self.mock_m365_parser.add_argument.call_args_list
init_modules_call = None
for call in calls:
if call[0][0] == "--init-modules":
init_modules_call = call
break
assert init_modules_call is not None
# Check argument configuration
kwargs = init_modules_call[1]
assert kwargs["action"] == "store_true"
assert "Initialize Microsoft 365 PowerShell modules" in kwargs["help"]
def test_region_argument_configuration(self):
"""Test that region argument is configured correctly"""
mock_m365_args = MagicMock()
mock_m365_args.subparsers = self.mock_subparsers
mock_m365_args.common_providers_parser = MagicMock()
arguments.init_parser(mock_m365_args)
# Find the region argument call
calls = self.mock_regions_group.add_argument.call_args_list
region_call = None
for call in calls:
if call[0][0] == "--region":
region_call = call
break
assert region_call is not None
# Check argument configuration
kwargs = region_call[1]
assert kwargs["nargs"] == "?"
assert kwargs["default"] == "M365Global"
assert kwargs["choices"] == [
"M365Global",
"M365GlobalChina",
"M365USGovernment",
]
assert "Microsoft 365 region" in kwargs["help"]
assert "M365Global" in kwargs["help"]
class TestM365ArgumentsIntegration:
def test_real_argument_parsing_az_cli_auth(self):
"""Test parsing arguments with Azure CLI authentication"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
common_parser = argparse.ArgumentParser(add_help=False)
# Create a mock object that mimics the structure used by the init_parser function
mock_m365_args = MagicMock()
mock_m365_args.subparsers = subparsers
mock_m365_args.common_providers_parser = common_parser
arguments.init_parser(mock_m365_args)
# Parse arguments with Azure CLI auth
args = parser.parse_args(["m365", "--az-cli-auth"])
assert args.az_cli_auth is True
assert args.env_auth is False
assert args.sp_env_auth is False
assert args.browser_auth is False
assert args.certificate_auth is False
def test_real_argument_parsing_env_auth(self):
"""Test parsing arguments with environment authentication"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
common_parser = argparse.ArgumentParser(add_help=False)
mock_m365_args = MagicMock()
mock_m365_args.subparsers = subparsers
mock_m365_args.common_providers_parser = common_parser
arguments.init_parser(mock_m365_args)
# Parse arguments with environment auth
args = parser.parse_args(["m365", "--env-auth"])
assert args.az_cli_auth is False
assert args.env_auth is True
assert args.sp_env_auth is False
assert args.browser_auth is False
assert args.certificate_auth is False
def test_real_argument_parsing_sp_env_auth(self):
"""Test parsing arguments with service principal environment authentication"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
common_parser = argparse.ArgumentParser(add_help=False)
mock_m365_args = MagicMock()
mock_m365_args.subparsers = subparsers
mock_m365_args.common_providers_parser = common_parser
arguments.init_parser(mock_m365_args)
# Parse arguments with service principal environment auth
args = parser.parse_args(["m365", "--sp-env-auth"])
assert args.az_cli_auth is False
assert args.env_auth is False
assert args.sp_env_auth is True
assert args.browser_auth is False
assert args.certificate_auth is False
def test_real_argument_parsing_browser_auth_with_tenant_id(self):
"""Test parsing arguments with browser authentication and tenant ID"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
common_parser = argparse.ArgumentParser(add_help=False)
mock_m365_args = MagicMock()
mock_m365_args.subparsers = subparsers
mock_m365_args.common_providers_parser = common_parser
arguments.init_parser(mock_m365_args)
# Parse arguments with browser auth and tenant ID
args = parser.parse_args(
[
"m365",
"--browser-auth",
"--tenant-id",
"12345678-1234-5678-abcd-123456789012",
]
)
assert args.az_cli_auth is False
assert args.env_auth is False
assert args.sp_env_auth is False
assert args.browser_auth is True
assert args.certificate_auth is False
assert args.tenant_id == "12345678-1234-5678-abcd-123456789012"
def test_real_argument_parsing_certificate_auth(self):
"""Test parsing arguments with certificate authentication"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
common_parser = argparse.ArgumentParser(add_help=False)
mock_m365_args = MagicMock()
mock_m365_args.subparsers = subparsers
mock_m365_args.common_providers_parser = common_parser
arguments.init_parser(mock_m365_args)
# Parse arguments with certificate auth
args = parser.parse_args(["m365", "--certificate-auth"])
assert args.az_cli_auth is False
assert args.env_auth is False
assert args.sp_env_auth is False
assert args.browser_auth is False
assert args.certificate_auth is True
def test_real_argument_parsing_with_init_modules(self):
"""Test parsing arguments with init modules flag"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
common_parser = argparse.ArgumentParser(add_help=False)
mock_m365_args = MagicMock()
mock_m365_args.subparsers = subparsers
mock_m365_args.common_providers_parser = common_parser
arguments.init_parser(mock_m365_args)
# Parse arguments with init modules
args = parser.parse_args(["m365", "--az-cli-auth", "--init-modules"])
assert args.az_cli_auth is True
assert args.init_modules is True
def test_real_argument_parsing_with_different_regions(self):
"""Test parsing arguments with different region options"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
common_parser = argparse.ArgumentParser(add_help=False)
mock_m365_args = MagicMock()
mock_m365_args.subparsers = subparsers
mock_m365_args.common_providers_parser = common_parser
arguments.init_parser(mock_m365_args)
# Test M365Global (default)
args = parser.parse_args(["m365", "--az-cli-auth"])
assert args.region == "M365Global"
# Test M365GlobalChina
args = parser.parse_args(
["m365", "--az-cli-auth", "--region", "M365GlobalChina"]
)
assert args.region == "M365GlobalChina"
# Test M365USGovernment
args = parser.parse_args(
["m365", "--az-cli-auth", "--region", "M365USGovernment"]
)
assert args.region == "M365USGovernment"
def test_real_argument_parsing_no_authentication_defaults(self):
"""Test parsing arguments without any authentication flags (should have defaults)"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
common_parser = argparse.ArgumentParser(add_help=False)
mock_m365_args = MagicMock()
mock_m365_args.subparsers = subparsers
mock_m365_args.common_providers_parser = common_parser
arguments.init_parser(mock_m365_args)
# Parse arguments without explicit auth (defaults should apply)
args = parser.parse_args(["m365"])
assert args.az_cli_auth is False
assert args.env_auth is False
assert args.sp_env_auth is False
assert args.browser_auth is False
assert args.certificate_auth is False
assert args.tenant_id is None
assert args.init_modules is False
assert args.region == "M365Global"
def test_real_argument_parsing_complete_configuration(self):
"""Test parsing arguments with all non-exclusive options"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
common_parser = argparse.ArgumentParser(add_help=False)
mock_m365_args = MagicMock()
mock_m365_args.subparsers = subparsers
mock_m365_args.common_providers_parser = common_parser
arguments.init_parser(mock_m365_args)
# Parse arguments with complete configuration
args = parser.parse_args(
[
"m365",
"--browser-auth",
"--tenant-id",
"12345678-1234-5678-abcd-123456789012",
"--init-modules",
"--region",
"M365USGovernment",
]
)
assert args.browser_auth is True
assert args.tenant_id == "12345678-1234-5678-abcd-123456789012"
assert args.init_modules is True
assert args.region == "M365USGovernment"
def test_mutually_exclusive_authentication_enforcement(self):
"""Test that authentication methods are mutually exclusive"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
common_parser = argparse.ArgumentParser(add_help=False)
mock_m365_args = MagicMock()
mock_m365_args.subparsers = subparsers
mock_m365_args.common_providers_parser = common_parser
arguments.init_parser(mock_m365_args)
# This should raise SystemExit due to mutually exclusive group
try:
parser.parse_args(["m365", "--az-cli-auth", "--env-auth"])
assert False, "Expected SystemExit due to mutually exclusive arguments"
except SystemExit:
# This is expected
pass
def test_tenant_id_without_arguments(self):
"""Test that tenant-id can be specified without an argument (optional value)"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
common_parser = argparse.ArgumentParser(add_help=False)
mock_m365_args = MagicMock()
mock_m365_args.subparsers = subparsers
mock_m365_args.common_providers_parser = common_parser
arguments.init_parser(mock_m365_args)
# Parse arguments with tenant-id but no value (should be None due to nargs="?")
args = parser.parse_args(["m365", "--az-cli-auth", "--tenant-id"])
assert args.tenant_id is None
def test_certificate_path_argument_configuration(self):
"""Test that certificate_path argument is properly configured"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
common_parser = argparse.ArgumentParser(add_help=False)
mock_m365_args = MagicMock()
mock_m365_args.subparsers = subparsers
mock_m365_args.common_providers_parser = common_parser
arguments.init_parser(mock_m365_args)
# Parse arguments with certificate-path
args = parser.parse_args(
["m365", "--certificate-auth", "--certificate-path", "/path/to/cert.pem"]
)
assert args.certificate_auth is True
assert args.certificate_path == "/path/to/cert.pem"
def test_certificate_path_without_value(self):
"""Test certificate_path argument without value (should be None due to nargs='?')"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
common_parser = argparse.ArgumentParser(add_help=False)
mock_m365_args = MagicMock()
mock_m365_args.subparsers = subparsers
mock_m365_args.common_providers_parser = common_parser
arguments.init_parser(mock_m365_args)
# Parse arguments with certificate-path but no value
args = parser.parse_args(["m365", "--certificate-auth", "--certificate-path"])
assert args.certificate_auth is True
assert args.certificate_path is None
def test_certificate_auth_with_certificate_path_integration(self):
"""Test certificate authentication with certificate path integration"""
parser = argparse.ArgumentParser()
subparsers = parser.add_subparsers()
common_parser = argparse.ArgumentParser(add_help=False)
mock_m365_args = MagicMock()
mock_m365_args.subparsers = subparsers
mock_m365_args.common_providers_parser = common_parser
arguments.init_parser(mock_m365_args)
# Parse complete certificate authentication arguments
args = parser.parse_args(
[
"m365",
"--certificate-auth",
"--certificate-path",
"/home/user/cert.pem",
"--tenant-id",
"12345678-1234-1234-1234-123456789012",
]
)
assert args.certificate_auth is True
assert args.certificate_path == "/home/user/cert.pem"
assert args.tenant_id == "12345678-1234-1234-1234-123456789012"
assert args.az_cli_auth is False
assert args.env_auth is False
assert args.sp_env_auth is False
assert args.browser_auth is False
@@ -1,9 +1,11 @@
import base64
from unittest.mock import MagicMock, call, patch
import pytest
from prowler.lib.powershell.powershell import PowerShellSession
from prowler.providers.m365.exceptions.exceptions import (
M365CertificateCreationError,
M365GraphConnectionError,
M365UserCredentialsError,
M365UserNotBelongingToTenantError,
@@ -112,7 +114,7 @@ class Testm365PowerShell:
session.close()
@patch("subprocess.Popen")
def test_test_credentials(self, mock_popen):
def test_test_credentials_exchange_success(self, mock_popen):
mock_process = MagicMock()
mock_popen.return_value = mock_process
@@ -131,16 +133,20 @@ class Testm365PowerShell:
tenant_domain="contoso.onmicrosoft.com",
tenant_domains=["contoso.onmicrosoft.com"],
location="test_location",
user="test@contoso.onmicrosoft.com",
)
session = M365PowerShell(credentials, identity)
# Mock encrypt_password to return a known value
session.encrypt_password = MagicMock(return_value="encrypted_password")
# Mock execute to simulate successful Connect-ExchangeOnline
session.execute = MagicMock(
return_value="Connected successfully https://aka.ms/exov3-module"
)
# Mock execute to simulate successful Exchange connection
def mock_execute_side_effect(command):
if "Connect-ExchangeOnline" in command:
return "Connected successfully https://aka.ms/exov3-module"
return ""
session.execute = MagicMock(side_effect=mock_execute_side_effect)
# Execute the test
result = session.test_credentials(credentials)
@@ -153,42 +159,110 @@ class Testm365PowerShell:
session.execute.assert_any_call(
f'$credential = New-Object System.Management.Automation.PSCredential("{session.sanitize(credentials.user)}", $securePassword)'
)
# Exchange connection should be tested
session.execute.assert_any_call(
"Connect-ExchangeOnline -Credential $credential"
)
# Verify Teams connection was NOT called (since Exchange succeeded)
teams_calls = [
call
for call in session.execute.call_args_list
if "Connect-MicrosoftTeams" in str(call)
]
assert (
len(teams_calls) == 0
), "Teams connection should not be called when Exchange succeeds"
session.close()
@patch("subprocess.Popen")
def test_test_credentials_exchange_fail_teams_success(self, mock_popen):
mock_process = MagicMock()
mock_popen.return_value = mock_process
credentials = M365Credentials(
user="test@contoso.onmicrosoft.com",
passwd="test_password",
encrypted_passwd="test_encrypted_password",
client_id="test_client_id",
client_secret="test_client_secret",
tenant_id="test_tenant_id",
)
identity = M365IdentityInfo(
identity_id="test_id",
identity_type="User",
tenant_id="test_tenant",
tenant_domain="contoso.onmicrosoft.com",
tenant_domains=["contoso.onmicrosoft.com"],
location="test_location",
user="test@contoso.onmicrosoft.com",
)
session = M365PowerShell(credentials, identity)
# Mock encrypt_password to return a known value
session.encrypt_password = MagicMock(return_value="encrypted_password")
# Mock execute to simulate Exchange fail and Teams success
def mock_execute_side_effect(command):
if "Connect-ExchangeOnline" in command:
return (
"Connection failed" # No "https://aka.ms/exov3-module" in response
)
elif "Connect-MicrosoftTeams" in command:
return "Connected successfully test@contoso.onmicrosoft.com"
return ""
session.execute = MagicMock(side_effect=mock_execute_side_effect)
# 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.encrypted_passwd}" | ConvertTo-SecureString'
)
session.execute.assert_any_call(
f'$credential = New-Object System.Management.Automation.PSCredential("{session.sanitize(credentials.user)}", $securePassword)'
)
# Both Exchange and Teams connections should be tested
session.execute.assert_any_call(
"Connect-ExchangeOnline -Credential $credential"
)
session.execute.assert_any_call(
"Connect-MicrosoftTeams -Credential $credential"
)
session.close()
@patch("subprocess.Popen")
def test_test_credentials_application_auth(self, mock_popen):
mock_process = MagicMock()
mock_popen.return_value = mock_process
with patch(
"prowler.providers.m365.lib.powershell.m365_powershell.M365Credentials",
autospec=True,
) as mock_creds:
credentials = mock_creds.return_value
credentials.user = ""
credentials.passwd = ""
credentials.encrypted_passwd = ""
credentials.client_id = "test_client_id"
credentials.client_secret = "test_client_secret"
credentials.tenant_id = "test_tenant_id"
identity = M365IdentityInfo(
identity_id="test_id",
identity_type="Application",
tenant_id="test_tenant",
tenant_domain="contoso.onmicrosoft.com",
tenant_domains=["contoso.onmicrosoft.com"],
location="test_location",
)
session = M365PowerShell(credentials, identity)
session.execute = MagicMock(return_value="sometoken")
credentials = M365Credentials(
user="",
passwd="",
encrypted_passwd="",
client_id="test_client_id",
client_secret="test_client_secret",
tenant_id="test_tenant_id",
)
identity = M365IdentityInfo(
identity_id="test_id",
identity_type="Application",
tenant_id="test_tenant",
tenant_domain="contoso.onmicrosoft.com",
tenant_domains=["contoso.onmicrosoft.com"],
location="test_location",
)
session = M365PowerShell(credentials, identity)
session.execute = MagicMock(return_value="sometoken")
result = session.test_credentials(credentials)
assert result is True
session.execute.assert_any_call("Write-Output $graphToken")
session.close()
result = session.test_credentials(credentials)
assert result is True
session.execute.assert_any_call("Write-Output $graphToken")
session.close()
@patch("subprocess.Popen")
@patch("msal.ConfidentialClientApplication")
@@ -748,7 +822,8 @@ class Testm365PowerShell:
assert result is True
# Verify all expected PowerShell commands were called
assert session.execute.call_count == 3
# 4 calls: teamstokenBody, teamsToken, Write-Output $teamsToken, Connect-MicrosoftTeams
assert session.execute.call_count == 4
mock_decode_jwt.assert_called_once_with("valid_teams_token")
session.close()
@@ -849,7 +924,8 @@ class Testm365PowerShell:
assert result is True
# Verify all expected PowerShell commands were called
assert session.execute.call_count == 3
# 4 calls: SecureSecret, exchangeToken, Write-Output $exchangeToken, Connect-ExchangeOnline
assert session.execute.call_count == 4
mock_decode_msal_token.assert_called_once_with("valid_exchange_token")
session.close()
@@ -970,3 +1046,623 @@ class Testm365PowerShell:
del sys.modules["win32crypt"]
session.close()
@patch("subprocess.Popen")
def test_clean_certificate_content(self, mock_popen):
"""Test clean_certificate_content method with various certificate content formats"""
mock_process = MagicMock()
mock_popen.return_value = mock_process
credentials = M365Credentials()
identity = M365IdentityInfo()
session = M365PowerShell(credentials, identity)
# Test with clean base64 content
clean_cert = "MIIDXTCCAkWgAwIBAgIJAJc1HiIAZAiIMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV"
result = session.clean_certificate_content(clean_cert)
assert result == clean_cert
# Test with newlines
cert_with_newlines = "MIIDXTCCAkWgAwIBAgIJAJc1HiIAZAiIMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV\nBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldA=="
expected = "MIIDXTCCAkWgAwIBAgIJAJc1HiIAZAiIMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldA=="
result = session.clean_certificate_content(cert_with_newlines)
assert result == expected
# Test with carriage returns
cert_with_cr = "MIIDXTCCAkWgAwIBAgIJAJc1HiIAZAiIMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV\rBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldA=="
result = session.clean_certificate_content(cert_with_cr)
assert result == expected
# Test with spaces
cert_with_spaces = "MIIDXTCCAkWgAwIBAgIJAJc1HiIAZAiIMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldA=="
result = session.clean_certificate_content(cert_with_spaces)
assert result == expected
# Test with combination of all whitespace types
cert_mixed = "MIIDXTCCAkWgAwIBAgIJAJc1HiIAZAiIMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNV\n\r BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldA=="
result = session.clean_certificate_content(cert_mixed)
assert result == expected
# Test with leading/trailing whitespace
cert_with_whitespace = " MIIDXTCCAkWgAwIBAgIJAJc1HiIAZAiIMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldA== "
result = session.clean_certificate_content(cert_with_whitespace)
assert result == expected
session.close()
@patch("subprocess.Popen")
def test_init_credential_certificate_auth(self, mock_popen):
"""Test init_credential method with certificate authentication"""
mock_process = MagicMock()
mock_popen.return_value = mock_process
certificate_content = base64.b64encode(b"fake_certificate").decode("utf-8")
credentials = M365Credentials(
client_id="test_client_id",
tenant_id="test_tenant_id",
certificate_content=certificate_content,
tenant_domains=["example.com"],
)
identity = M365IdentityInfo(tenant_domains=["example.com"])
# Create session without calling init_credential
with patch.object(M365PowerShell, "init_credential"):
session = M365PowerShell(credentials, identity)
# Mock the execute method
execute_calls = []
def mock_execute(command):
execute_calls.append(command)
if (
"New-Object System.Security.Cryptography.X509Certificates.X509Certificate2"
in command
):
return "" # No error
return ""
session.execute = MagicMock(side_effect=mock_execute)
session.sanitize = MagicMock(side_effect=lambda x: x)
session.clean_certificate_content = MagicMock(return_value=certificate_content)
# Now call init_credential
session.init_credential(credentials)
# Verify clean_certificate_content was called
session.clean_certificate_content.assert_called_once_with(certificate_content)
# Verify sanitize was called for client_id and tenant_id
session.sanitize.assert_any_call(credentials.client_id)
session.sanitize.assert_any_call(credentials.tenant_id)
# Verify execute was called with correct commands
session.execute.assert_any_call(
f'$certBytes = [Convert]::FromBase64String("{certificate_content}")'
)
session.execute.assert_any_call(
"$certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2(,$certBytes)"
)
session.execute.assert_any_call(f'$clientID = "{credentials.client_id}"')
session.execute.assert_any_call(f'$tenantID = "{credentials.tenant_id}"')
session.execute.assert_any_call(
f'$tenantDomain = "{credentials.tenant_domains[0]}"'
)
session.close()
@patch("subprocess.Popen")
def test_init_credential_certificate_auth_creation_error(self, mock_popen):
"""Test init_credential method with certificate authentication when certificate creation fails"""
mock_process = MagicMock()
mock_popen.return_value = mock_process
certificate_content = base64.b64encode(b"invalid_certificate").decode("utf-8")
credentials = M365Credentials(
client_id="test_client_id",
tenant_id="test_tenant_id",
certificate_content=certificate_content,
tenant_domains=["example.com"],
)
identity = M365IdentityInfo(tenant_domains=["example.com"])
# Create session without calling init_credential
with patch.object(M365PowerShell, "init_credential"):
session = M365PowerShell(credentials, identity)
# Mock the execute method to simulate certificate creation error
def mock_execute(command):
if (
"New-Object System.Security.Cryptography.X509Certificates.X509Certificate2"
in command
):
return "Certificate creation failed: Invalid certificate format"
return ""
session.execute = MagicMock(side_effect=mock_execute)
session.sanitize = MagicMock(side_effect=lambda x: x)
session.clean_certificate_content = MagicMock(return_value=certificate_content)
with pytest.raises(M365CertificateCreationError) as exc_info:
session.init_credential(credentials)
# The actual error message format from the exception
assert "Failed to create X.509 certificate object" in str(exc_info.value)
session.close()
@patch("subprocess.Popen")
def test_test_exchange_certificate_connection_success(self, mock_popen):
"""Test test_exchange_certificate_connection method with successful connection"""
mock_process = MagicMock()
mock_popen.return_value = mock_process
credentials = M365Credentials()
identity = M365IdentityInfo()
session = M365PowerShell(credentials, identity)
# Mock successful Exchange connection
session.execute = MagicMock(
return_value="Connected successfully https://aka.ms/exov3-module"
)
result = session.test_exchange_certificate_connection()
assert result is True
session.execute.assert_called_once_with(
"Connect-ExchangeOnline -Certificate $certificate -AppId $clientID -Organization $tenantDomain"
)
session.close()
@patch("subprocess.Popen")
def test_test_exchange_certificate_connection_failure(self, mock_popen):
"""Test test_exchange_certificate_connection method with failed connection"""
mock_process = MagicMock()
mock_popen.return_value = mock_process
credentials = M365Credentials()
identity = M365IdentityInfo()
session = M365PowerShell(credentials, identity)
# Mock failed Exchange connection
session.execute = MagicMock(
return_value="Connection failed: Authentication error"
)
result = session.test_exchange_certificate_connection()
assert result is False
session.execute.assert_called_once_with(
"Connect-ExchangeOnline -Certificate $certificate -AppId $clientID -Organization $tenantDomain"
)
session.close()
@patch("subprocess.Popen")
def test_test_teams_certificate_connection_success(self, mock_popen):
"""Test test_teams_certificate_connection method with successful connection"""
mock_process = MagicMock()
mock_popen.return_value = mock_process
credentials = M365Credentials()
identity = M365IdentityInfo(identity_id="test_identity_id")
# Create session without calling init_credential
with patch.object(M365PowerShell, "init_credential"):
session = M365PowerShell(credentials, identity)
# Mock successful Teams connection - the method returns bool
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"
return ""
session.execute = MagicMock(side_effect=mock_execute_side_effect)
result = session.test_teams_certificate_connection()
assert result is True
session.execute.assert_called_once_with(
"Connect-MicrosoftTeams -Certificate $certificate -ApplicationId $clientID -TenantId $tenantID"
)
session.close()
@patch("subprocess.Popen")
def test_test_teams_certificate_connection_failure(self, mock_popen):
"""Test test_teams_certificate_connection method with failed connection"""
mock_process = MagicMock()
mock_popen.return_value = mock_process
credentials = M365Credentials()
identity = M365IdentityInfo()
session = M365PowerShell(credentials, identity)
# Mock failed Teams connection
def mock_execute_side_effect(command, json_parse=False):
if "Connect-MicrosoftTeams" in command:
raise Exception("Connection failed: Authentication error")
return ""
session.execute = MagicMock(side_effect=mock_execute_side_effect)
# Should raise exception on connection failure
with pytest.raises(Exception) as exc_info:
session.test_teams_certificate_connection()
assert "Connection failed: Authentication error" in str(exc_info.value)
session.close()
@patch("subprocess.Popen")
def test_test_credentials_certificate_auth_success(self, mock_popen):
"""Test test_credentials method with certificate authentication - successful"""
mock_process = MagicMock()
mock_popen.return_value = mock_process
certificate_content = base64.b64encode(b"fake_certificate").decode("utf-8")
credentials = M365Credentials(
client_id="test_client_id", certificate_content=certificate_content
)
identity = M365IdentityInfo()
# Create session without calling init_credential
with patch.object(M365PowerShell, "init_credential"):
session = M365PowerShell(credentials, identity)
# Mock successful certificate connections
# Note: The actual implementation uses "or" so if teams succeeds, exchange won't be called
session.test_teams_certificate_connection = MagicMock(return_value=True)
session.test_exchange_certificate_connection = MagicMock(return_value=True)
result = session.test_credentials(credentials)
assert result is True
session.test_teams_certificate_connection.assert_called_once()
# Exchange connection should NOT be called if teams connection succeeds (due to "or" logic)
session.test_exchange_certificate_connection.assert_not_called()
session.close()
@patch("subprocess.Popen")
def test_test_credentials_certificate_auth_failure(self, mock_popen):
"""Test test_credentials method with certificate authentication - failure"""
mock_process = MagicMock()
mock_popen.return_value = mock_process
certificate_content = base64.b64encode(b"fake_certificate").decode("utf-8")
credentials = M365Credentials(
client_id="test_client_id", certificate_content=certificate_content
)
identity = M365IdentityInfo()
# Create session without calling init_credential
with patch.object(M365PowerShell, "init_credential"):
session = M365PowerShell(credentials, identity)
# Mock failed certificate connections - teams fails, so exchange is tried
session.test_teams_certificate_connection = MagicMock(return_value=False)
session.test_exchange_certificate_connection = MagicMock(return_value=False)
result = session.test_credentials(credentials)
assert result is True # Method always returns True after the try block
session.close()
@patch("subprocess.Popen")
def test_connect_microsoft_teams_certificate_auth(self, mock_popen):
"""Test connect_microsoft_teams method with certificate authentication"""
mock_process = MagicMock()
mock_popen.return_value = mock_process
credentials = M365Credentials()
identity = M365IdentityInfo()
session = M365PowerShell(credentials, identity)
# Mock certificate variable check and teams connection
execute_calls = []
def mock_execute_side_effect(command, json_parse=False):
execute_calls.append(command)
if "Write-Output $certificate" in command:
return "certificate_content" # Non-empty means certificate exists
elif "Connect-MicrosoftTeams" in command:
return {"TenantId": "test-tenant-id", "Account": "test-account"}
return ""
session.execute = MagicMock(side_effect=mock_execute_side_effect)
session.test_teams_certificate_connection = MagicMock(
return_value={"success": True}
)
result = session.connect_microsoft_teams()
assert result == {"success": True}
session.test_teams_certificate_connection.assert_called_once()
session.close()
@patch("subprocess.Popen")
def test_connect_exchange_online_certificate_auth(self, mock_popen):
"""Test connect_exchange_online method with certificate authentication"""
mock_process = MagicMock()
mock_popen.return_value = mock_process
credentials = M365Credentials()
identity = M365IdentityInfo()
session = M365PowerShell(credentials, identity)
# Mock certificate variable check and exchange connection
execute_calls = []
def mock_execute_side_effect(command, json_parse=False):
execute_calls.append(command)
if "Write-Output $certificate" in command:
return "certificate_content" # Non-empty means certificate exists
return ""
session.execute = MagicMock(side_effect=mock_execute_side_effect)
session.test_exchange_certificate_connection = MagicMock(return_value=True)
result = session.connect_exchange_online()
assert result is True
session.test_exchange_certificate_connection.assert_called_once()
session.close()
@patch("subprocess.Popen")
def test_clean_certificate_content_basic(self, mock_popen):
"""Test clean_certificate_content method with basic certificate content"""
mock_process = MagicMock()
mock_popen.return_value = mock_process
mock_process.returncode = 0
session = M365PowerShell(
M365Credentials(
client_id="test_client_id",
client_secret="test_secret",
tenant_id="test_tenant_id",
tenant_domains=["contoso.com"],
),
M365IdentityInfo(
tenant_id="test_tenant_id",
tenant_domain="contoso.com",
tenant_domains=["contoso.com"],
identity_id="test_identity_id",
identity_type="Service Principal",
),
)
# Test basic certificate content cleaning
cert_content = "LS0tLS1CRUdJTi\nBFUlRJRklD\rQVRFLS0tLS0\n"
expected = "LS0tLS1CRUdJTiBFUlRJRklDQVRFLS0tLS0"
result = session.clean_certificate_content(cert_content)
assert result == expected
session.close()
@patch("subprocess.Popen")
def test_clean_certificate_content_with_spaces(self, mock_popen):
"""Test clean_certificate_content method with spaces in certificate content"""
mock_process = MagicMock()
mock_popen.return_value = mock_process
mock_process.returncode = 0
session = M365PowerShell(
M365Credentials(
client_id="test_client_id",
client_secret="test_secret",
tenant_id="test_tenant_id",
tenant_domains=["contoso.com"],
),
M365IdentityInfo(
tenant_id="test_tenant_id",
tenant_domain="contoso.com",
tenant_domains=["contoso.com"],
identity_id="test_identity_id",
identity_type="Service Principal",
),
)
# Test certificate content with spaces
cert_content = " LS0tLS1CRUdJTi BFUlRJRklD QVRFLS0tLS0 "
expected = "LS0tLS1CRUdJTiBFUlRJRklDQVRFLS0tLS0"
result = session.clean_certificate_content(cert_content)
assert result == expected
session.close()
@patch("subprocess.Popen")
def test_clean_certificate_content_empty(self, mock_popen):
"""Test clean_certificate_content method with empty certificate content"""
mock_process = MagicMock()
mock_popen.return_value = mock_process
mock_process.returncode = 0
session = M365PowerShell(
M365Credentials(
client_id="test_client_id",
client_secret="test_secret",
tenant_id="test_tenant_id",
tenant_domains=["contoso.com"],
),
M365IdentityInfo(
tenant_id="test_tenant_id",
tenant_domain="contoso.com",
tenant_domains=["contoso.com"],
identity_id="test_identity_id",
identity_type="Service Principal",
),
)
# Test empty certificate content
cert_content = ""
expected = ""
result = session.clean_certificate_content(cert_content)
assert result == expected
session.close()
@patch("subprocess.Popen")
def test_init_credential_certificate_auth_with_domains(self, mock_popen):
"""Test init_credential method with certificate authentication and tenant domains"""
certificate_content = base64.b64encode(b"fake_certificate").decode("utf-8")
mock_process = MagicMock()
mock_popen.return_value = mock_process
mock_process.returncode = 0
executed_commands = []
def mock_execute(command):
executed_commands.append(command)
if "Error creating certificate" in command:
return None # No error
return ""
# Create session with non-certificate credentials first
session = M365PowerShell(
M365Credentials(
client_id="test_client_id",
client_secret="test_secret",
tenant_id="test_tenant_id",
tenant_domains=["contoso.com", "subdomain.contoso.com"],
),
M365IdentityInfo(
tenant_id="test_tenant_id",
tenant_domain="contoso.com",
tenant_domains=["contoso.com", "subdomain.contoso.com"],
identity_id="test_identity_id",
identity_type="Service Principal with Certificate",
),
)
# Mock methods before calling init_credential
session.execute = MagicMock(side_effect=mock_execute)
session.sanitize = MagicMock(side_effect=lambda x: x)
session.clean_certificate_content = MagicMock(return_value=certificate_content)
# Now test certificate authentication
session.init_credential(
M365Credentials(
client_id="test_client_id",
tenant_id="test_tenant_id",
certificate_content=certificate_content,
tenant_domains=["contoso.com", "subdomain.contoso.com"],
)
)
# Verify that certificate content was cleaned
session.clean_certificate_content.assert_called_once_with(certificate_content)
# Verify certificate creation commands were executed
assert any(
"$certBytes = [Convert]::FromBase64String" in cmd
for cmd in executed_commands
)
assert any(
"$certificate = New-Object System.Security.Cryptography.X509Certificates.X509Certificate2"
in cmd
for cmd in executed_commands
)
assert any('$clientID = "test_client_id"' in cmd for cmd in executed_commands)
assert any('$tenantID = "test_tenant_id"' in cmd for cmd in executed_commands)
assert any('$tenantDomain = "contoso.com"' in cmd for cmd in executed_commands)
session.close()
@patch("subprocess.Popen")
def test_test_credentials_certificate_auth_with_or_logic(self, mock_popen):
"""Test test_credentials method with certificate auth using OR logic between Teams and Exchange"""
certificate_content = base64.b64encode(b"fake_certificate").decode("utf-8")
mock_process = MagicMock()
mock_popen.return_value = mock_process
mock_process.returncode = 0
# Create session with non-certificate credentials first
session = M365PowerShell(
M365Credentials(
client_id="test_client_id",
client_secret="test_secret",
tenant_id="test_tenant_id",
tenant_domains=["contoso.com"],
),
M365IdentityInfo(
tenant_id="test_tenant_id",
tenant_domain="contoso.com",
tenant_domains=["contoso.com"],
identity_id="test_identity_id",
identity_type="Service Principal with Certificate",
),
)
# Mock that Teams connection fails but Exchange succeeds
session.test_teams_certificate_connection = MagicMock(return_value=False)
session.test_exchange_certificate_connection = MagicMock(return_value=True)
result = session.test_credentials(
M365Credentials(
client_id="test_client_id",
tenant_id="test_tenant_id",
certificate_content=certificate_content,
tenant_domains=["contoso.com"],
)
)
assert result is True
session.test_teams_certificate_connection.assert_called_once()
session.test_exchange_certificate_connection.assert_called_once()
session.close()
@patch("subprocess.Popen")
def test_test_credentials_certificate_auth_both_fail(self, mock_popen):
"""Test test_credentials method with certificate auth when both Teams and Exchange fail"""
certificate_content = base64.b64encode(b"fake_certificate").decode("utf-8")
mock_process = MagicMock()
mock_popen.return_value = mock_process
mock_process.returncode = 0
# Create session with non-certificate credentials first
session = M365PowerShell(
M365Credentials(
client_id="test_client_id",
client_secret="test_secret",
tenant_id="test_tenant_id",
tenant_domains=["contoso.com"],
),
M365IdentityInfo(
tenant_id="test_tenant_id",
tenant_domain="contoso.com",
tenant_domains=["contoso.com"],
identity_id="test_identity_id",
identity_type="Service Principal with Certificate",
),
)
# Mock that both connections fail
session.test_teams_certificate_connection = MagicMock(return_value=False)
session.test_exchange_certificate_connection = MagicMock(return_value=False)
# Even when both fail, the method should return True (this is the intended logic)
result = session.test_credentials(
M365Credentials(
client_id="test_client_id",
tenant_id="test_tenant_id",
certificate_content=certificate_content,
tenant_domains=["contoso.com"],
)
)
assert result is True
session.test_teams_certificate_connection.assert_called_once()
session.test_exchange_certificate_connection.assert_called_once()
session.close()
+2 -1
View File
@@ -1,5 +1,6 @@
from unittest.mock import MagicMock
from azure.identity import DefaultAzureCredential
from mock import MagicMock
from prowler.providers.m365.m365_provider import M365Provider
from prowler.providers.m365.models import (
File diff suppressed because it is too large Load Diff