feat(m365powershell): add pwsh authentication via service principal (#7992)
Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com> Co-authored-by: Adrián Jesús Peña Rodríguez <adrianjpr@gmail.com> Co-authored-by: Daniel Barranquero <danielbo2001@gmail.com> Co-authored-by: MrCloudSec <hello@mistercloudsec.com>
@@ -6,7 +6,8 @@ LABEL org.opencontainers.image.source="https://github.com/prowler-cloud/prowler"
|
||||
ARG POWERSHELL_VERSION=7.5.0
|
||||
|
||||
# hadolint ignore=DL3008
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends wget libicu72 \
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
wget libicu72 libunwind8 libssl3 libcurl4 ca-certificates apt-transport-https gnupg \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install PowerShell
|
||||
@@ -46,10 +47,6 @@ ENV PATH="${HOME}/.local/bin:${PATH}"
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir poetry
|
||||
|
||||
# By default poetry does not compile Python source files to bytecode during installation.
|
||||
# This speeds up the installation process, but the first execution may take a little more
|
||||
# time because Python then compiles source files to bytecode automatically. If you want to
|
||||
# compile source files to bytecode during installation, you can use the --compile option
|
||||
RUN poetry install --compile && \
|
||||
rm -rf ~/.cache/pip
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ All notable changes to the **Prowler API** are documented in this file.
|
||||
|
||||
### Changed
|
||||
- Reworked `GET /compliance-overviews` to return proper requirement metrics [(#7877)](https://github.com/prowler-cloud/prowler/pull/7877)
|
||||
- Optional `user` and `password` for M365 provider [(#7992)](https://github.com/prowler-cloud/prowler/pull/7992)
|
||||
|
||||
### Fixed
|
||||
- Scheduled scans are no longer deleted when their daily schedule run is disabled [(#8082)](https://github.com/prowler-cloud/prowler/pull/8082)
|
||||
|
||||
@@ -4934,7 +4934,6 @@ files = [
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f66efbc1caa63c088dead1c4170d148eabc9b80d95fb75b6c92ac0aad2437d76"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:22353049ba4181685023b25b5b51a574bce33e7f51c759371a7422dcae5402a6"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:932205970b9f9991b34f55136be327501903f7c66830e9760a8ffb15b07f05cd"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a52d48f4e7bf9005e8f0a89209bf9a73f7190ddf0489eee5eb51377385f59f2a"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win32.whl", hash = "sha256:3eac5a91891ceb88138c113f9db04f3cebdae277f5d44eaa3651a4f573e6a5da"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp310-cp310-win_amd64.whl", hash = "sha256:ab007f2f5a87bd08ab1499bdf96f3d5c6ad4dcfa364884cb4549aa0154b13a28"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-macosx_13_0_arm64.whl", hash = "sha256:4a6679521a58256a90b0d89e03992c15144c5f3858f40d7c18886023d7943db6"},
|
||||
@@ -4943,7 +4942,6 @@ files = [
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:811ea1594b8a0fb466172c384267a4e5e367298af6b228931f273b111f17ef52"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:cf12567a7b565cbf65d438dec6cfbe2917d3c1bdddfce84a9930b7d35ea59642"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:7dd5adc8b930b12c8fc5b99e2d535a09889941aa0d0bd06f4749e9a9397c71d2"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1492a6051dab8d912fc2adeef0e8c72216b24d57bd896ea607cb90bb0c4981d3"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win32.whl", hash = "sha256:bd0a08f0bab19093c54e18a14a10b4322e1eacc5217056f3c063bd2f59853ce4"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp311-cp311-win_amd64.whl", hash = "sha256:a274fb2cb086c7a3dea4322ec27f4cb5cc4b6298adb583ab0e211a4682f241eb"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:20b0f8dc160ba83b6dcc0e256846e1a02d044e13f7ea74a3d1d56ede4e48c632"},
|
||||
@@ -4952,7 +4950,6 @@ files = [
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:749c16fcc4a2b09f28843cda5a193e0283e47454b63ec4b81eaa2242f50e4ccd"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bf165fef1f223beae7333275156ab2022cffe255dcc51c27f066b4370da81e31"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:32621c177bbf782ca5a18ba4d7af0f1082a3f6e517ac2a18b3974d4edf349680"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b82a7c94a498853aa0b272fd5bc67f29008da798d4f93a2f9f289feb8426a58d"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win32.whl", hash = "sha256:e8c4ebfcfd57177b572e2040777b8abc537cdef58a2120e830124946aa9b42c5"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp312-cp312-win_amd64.whl", hash = "sha256:0467c5965282c62203273b838ae77c0d29d7638c8a4e3a1c8bdd3602c10904e4"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4c8c5d82f50bb53986a5e02d1b3092b03622c02c2eb78e29bec33fd9593bae1a"},
|
||||
@@ -4961,7 +4958,6 @@ files = [
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96777d473c05ee3e5e3c3e999f5d23c6f4ec5b0c38c098b3a5229085f74236c6"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:3bc2a80e6420ca8b7d3590791e2dfc709c88ab9152c00eeb511c9875ce5778bf"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e188d2699864c11c36cdfdada94d781fd5d6b0071cd9c427bceb08ad3d7c70e1"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f6f3eac23941b32afccc23081e1f50612bdbe4e982012ef4f5797986828cd01"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win32.whl", hash = "sha256:6442cb36270b3afb1b4951f060eccca1ce49f3d087ca1ca4563a6eb479cb3de6"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp313-cp313-win_amd64.whl", hash = "sha256:e5b8daf27af0b90da7bb903a876477a9e6d7270be6146906b276605997c7e9a3"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-macosx_12_0_arm64.whl", hash = "sha256:fc4b630cd3fa2cf7fce38afa91d7cfe844a9f75d7f0f36393fa98815e911d987"},
|
||||
@@ -4970,7 +4966,6 @@ files = [
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e2f1c3765db32be59d18ab3953f43ab62a761327aafc1594a2a1fbe038b8b8a7"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:d85252669dc32f98ebcd5d36768f5d4faeaeaa2d655ac0473be490ecdae3c285"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:e143ada795c341b56de9418c58d028989093ee611aa27ffb9b7f609c00d813ed"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:2c59aa6170b990d8d2719323e628aaf36f3bfbc1c26279c0eeeb24d05d2d11c7"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win32.whl", hash = "sha256:beffaed67936fbbeffd10966a4eb53c402fafd3d6833770516bf7314bc6ffa12"},
|
||||
{file = "ruamel.yaml.clib-0.2.12-cp39-cp39-win_amd64.whl", hash = "sha256:040ae85536960525ea62868b642bdb0c2cc6021c9f9d507810c0c604e66f5a7b"},
|
||||
{file = "ruamel.yaml.clib-0.2.12.tar.gz", hash = "sha256:6c8fbb13ec503f99a91901ab46e0b07ae7941cd527393187039aec586fdfd36f"},
|
||||
|
||||
@@ -1244,10 +1244,10 @@ class TestProviderViewSet:
|
||||
("uid.icontains", "1", 5),
|
||||
("alias", "aws_testing_1", 1),
|
||||
("alias.icontains", "aws", 2),
|
||||
("inserted_at", TODAY, 5),
|
||||
("inserted_at.gte", "2024-01-01", 5),
|
||||
("inserted_at", TODAY, 6),
|
||||
("inserted_at.gte", "2024-01-01", 6),
|
||||
("inserted_at.lte", "2024-01-01", 0),
|
||||
("updated_at.gte", "2024-01-01", 5),
|
||||
("updated_at.gte", "2024-01-01", 6),
|
||||
("updated_at.lte", "2024-01-01", 0),
|
||||
]
|
||||
),
|
||||
@@ -1726,6 +1726,50 @@ class TestProviderSecretViewSet:
|
||||
"kubeconfig_content": "kubeconfig-content",
|
||||
},
|
||||
),
|
||||
# M365 with STATIC secret - no user or password
|
||||
(
|
||||
Provider.ProviderChoices.M365.value,
|
||||
ProviderSecret.TypeChoices.STATIC,
|
||||
{
|
||||
"client_id": "client-id",
|
||||
"client_secret": "client-secret",
|
||||
"tenant_id": "tenant-id",
|
||||
},
|
||||
),
|
||||
# M365 with user only
|
||||
(
|
||||
Provider.ProviderChoices.M365.value,
|
||||
ProviderSecret.TypeChoices.STATIC,
|
||||
{
|
||||
"client_id": "client-id",
|
||||
"client_secret": "client-secret",
|
||||
"tenant_id": "tenant-id",
|
||||
"user": "test@domain.com",
|
||||
},
|
||||
),
|
||||
# M365 with password only
|
||||
(
|
||||
Provider.ProviderChoices.M365.value,
|
||||
ProviderSecret.TypeChoices.STATIC,
|
||||
{
|
||||
"client_id": "client-id",
|
||||
"client_secret": "client-secret",
|
||||
"tenant_id": "tenant-id",
|
||||
"password": "supersecret",
|
||||
},
|
||||
),
|
||||
# M365 with user and password
|
||||
(
|
||||
Provider.ProviderChoices.M365.value,
|
||||
ProviderSecret.TypeChoices.STATIC,
|
||||
{
|
||||
"client_id": "client-id",
|
||||
"client_secret": "client-secret",
|
||||
"tenant_id": "tenant-id",
|
||||
"user": "test@domain.com",
|
||||
"password": "supersecret",
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_provider_secrets_create_valid(
|
||||
@@ -1737,7 +1781,10 @@ class TestProviderSecretViewSet:
|
||||
secret_data,
|
||||
):
|
||||
# Get the provider from the fixture and set its type
|
||||
provider = Provider.objects.filter(provider=provider_type)[0]
|
||||
try:
|
||||
provider = Provider.objects.filter(provider=provider_type)[0]
|
||||
except IndexError:
|
||||
print(f"Provider {provider_type} not found")
|
||||
|
||||
data = {
|
||||
"data": {
|
||||
|
||||
@@ -1200,8 +1200,8 @@ class M365ProviderSecret(serializers.Serializer):
|
||||
client_id = serializers.CharField()
|
||||
client_secret = serializers.CharField()
|
||||
tenant_id = serializers.CharField()
|
||||
user = serializers.EmailField()
|
||||
password = serializers.CharField()
|
||||
user = serializers.EmailField(required=False)
|
||||
password = serializers.CharField(required=False)
|
||||
|
||||
class Meta:
|
||||
resource_name = "provider-secrets"
|
||||
|
||||
@@ -381,8 +381,14 @@ def providers_fixture(tenants_fixture):
|
||||
tenant_id=tenant.id,
|
||||
scanner_args={"key1": "value1", "key2": {"key21": "value21"}},
|
||||
)
|
||||
provider6 = Provider.objects.create(
|
||||
provider="m365",
|
||||
uid="m365.test.com",
|
||||
alias="m365_testing",
|
||||
tenant_id=tenant.id,
|
||||
)
|
||||
|
||||
return provider1, provider2, provider3, provider4, provider5
|
||||
return provider1, provider2, provider3, provider4, provider5, provider6
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
||||
@@ -139,7 +139,7 @@ Prowler for M365 currently supports the following authentication types:
|
||||
???+ warning
|
||||
For Prowler App only the Service Principal with User Credentials authentication method is supported.
|
||||
|
||||
### Service Principal authentication
|
||||
### Service Principal authentication (recommended)
|
||||
|
||||
Authentication flag: `--sp-env-auth`
|
||||
|
||||
@@ -154,9 +154,11 @@ export AZURE_TENANT_ID="XXXXXXXXX"
|
||||
If you try to execute Prowler with the `--sp-env-auth` flag and those variables are empty or not exported, the execution is going to fail.
|
||||
Follow the instructions in the [Create Prowler Service Principal](../tutorials/microsoft365/getting-started-m365.md#create-the-service-principal-app) section to create a service principal.
|
||||
|
||||
With this credentials you will only be able to run the checks that work through MS Graph, this means that you won't run all the provider. If you want to scan all the checks from M365 you will need to use the recommended authentication method.
|
||||
If you don't add the external API permissions described in the mentioned section above you will only be able to run the checks that work through MS Graph. This means that you won't run all the provider.
|
||||
|
||||
### Service Principal and User Credentials authentication (recommended)
|
||||
If you want to scan all the checks from M365 you will need to use the recommended authentication method or add the external API permissions.
|
||||
|
||||
### Service Principal and User Credentials authentication
|
||||
|
||||
Authentication flag: `--env-auth`
|
||||
|
||||
@@ -213,6 +215,8 @@ Prowler for M365 requires two types of permission scopes to be set (if you want
|
||||
- `Policy.Read.All`: Required for all services.
|
||||
- `SharePointTenantSettings.Read.All`: Required for SharePoint service.
|
||||
- `User.Read` (IMPORTANT: this must be set as **delegated**): Required for the sign-in.
|
||||
- `Exchange.ManageAsApp` from external API `Office 365 Exchange Online`: Required for Exchange PowerShell module app authentication. You also need to assign the `Exchange Administrator` role to the app.
|
||||
- `application_access` from external API `Skype and Teams Tenant Admin API`: Required for Teams PowerShell module app authentication.
|
||||
|
||||
???+ note
|
||||
You can replace `Directory.Read.All` with `Domain.Read.All` is a more restrictive permission but you won't be able to run the Entra checks related with DirectoryRoles and GetUsers.
|
||||
@@ -221,7 +225,8 @@ Prowler for M365 requires two types of permission scopes to be set (if you want
|
||||
|
||||
|
||||
|
||||
- **Powershell Modules Permissions**: These are set at the `M365_USER` level, so the user used to run Prowler must have one of the following roles:
|
||||
|
||||
- **Powershell Modules Permissions** (if using user credentials): These are set at the `M365_USER` level, so the user used to run Prowler must have one of the following roles:
|
||||
- `Global Reader` (recommended): this allows you to read all roles needed.
|
||||
- `Exchange Administrator` and `Teams Administrator`: user needs both roles but with this [roles](https://learn.microsoft.com/en-us/exchange/permissions-exo/permissions-exo#microsoft-365-permissions-in-exchange-online) you can access to the same information as a Global Reader (since only read access is needed, Global Reader is recommended).
|
||||
|
||||
@@ -439,6 +444,7 @@ The required modules are:
|
||||
|
||||
- [ExchangeOnlineManagement](https://www.powershellgallery.com/packages/ExchangeOnlineManagement/3.6.0): Minimum version 3.6.0. Required for several checks across Exchange, Defender, and Purview.
|
||||
- [MicrosoftTeams](https://www.powershellgallery.com/packages/MicrosoftTeams/6.6.0): Minimum version 6.6.0. Required for all Teams checks.
|
||||
- [MSAL.PS](https://www.powershellgallery.com/packages/MSAL.PS/4.32.0): Required for Exchange module via application authentication.
|
||||
|
||||
## GitHub
|
||||
### Authentication
|
||||
|
||||
@@ -572,12 +572,12 @@ With M365 you need to specify which auth method is going to be used:
|
||||
|
||||
```console
|
||||
|
||||
# To use service principal authentication for MSGraph and PowerShell modules
|
||||
prowler m365 --sp-env-auth
|
||||
|
||||
# To use both service principal (for MSGraph) and user credentials (for PowerShell modules)
|
||||
prowler m365 --env-auth
|
||||
|
||||
# To use service principal authentication
|
||||
prowler m365 --sp-env-auth
|
||||
|
||||
# To use az cli authentication
|
||||
prowler m365 --az-cli-auth
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ With this done you will have all the needed keys, summarized in the following ta
|
||||
|
||||
---
|
||||
|
||||
### Grant required API permissions
|
||||
### Grant required Graph API permissions
|
||||
|
||||
Assign the following Microsoft Graph permissions:
|
||||
|
||||
@@ -100,7 +100,7 @@ Assign the following Microsoft Graph permissions:
|
||||
- `Directory.Read.All`: Required for all services.
|
||||
- `Policy.Read.All`: Required for all services.
|
||||
- `SharePointTenantSettings.Read.All`: Required for SharePoint service.
|
||||
- `User.Read` (IMPORTANT: this is set as **delegated**): Required for the sign-in.
|
||||
- `User.Read` (IMPORTANT: this is set as **delegated**): Required for the sign-in only if using user authentication.
|
||||
|
||||
???+ note
|
||||
You can replace `Directory.Read.All` with `Domain.Read.All` is a more restrictive permission but you won't be able to run the Entra checks related with DirectoryRoles and GetUsers.
|
||||
@@ -128,18 +128,83 @@ Follow these steps to assign the permissions:
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
4. Click `+ Add a permission` > `Microsoft Graph` > `Delegated permissions`
|
||||
|
||||

|
||||
### Grant PowerShell modules permissions
|
||||
|
||||
5. Search and select:
|
||||
The permissions you need to grant depends on whether you are using user credentials or service principal to authenticate to the M365 modules.
|
||||
|
||||
???+ warning "Warning"
|
||||
Make sure you add the correct set of permissions for the authentication method you are using.
|
||||
|
||||
|
||||
#### If using application(service principal) authentication
|
||||
|
||||
???+ warning "Warning"
|
||||
Currently Prowler Cloud only supports user authentication.
|
||||
|
||||
To grant the permissions for the PowerShell modules via application authentication, you need to add the necessary APIs to your app registration.
|
||||
|
||||
???+ warning "Warning"
|
||||
You need to have a license that allows you to use the APIs.
|
||||
|
||||
1. Add Exchange API:
|
||||
|
||||
- Search and select`Office 365 Exchange Online` API in **APIs my organization uses**.
|
||||
|
||||

|
||||
|
||||
- Select `Exchange.ManageAsApp` permission and click on `Add permissions`.
|
||||
|
||||

|
||||
|
||||
You also need to assign the `Exchange Administrator` role to the app. For that go to `Roles and administrators` and in the `Administrative roles` section click `here` to go to the directory level assignment:
|
||||
|
||||

|
||||
|
||||
Once in the directory level assignment, search for `Exchange Administrator` and click on it to open the assginments page of that role.
|
||||
|
||||

|
||||
|
||||
Click on `Add assignments`, search for your app and click on `Assign`.
|
||||
|
||||
You have to select it as `Active` and click on `Assign` to assign the role to the app.
|
||||
|
||||

|
||||
|
||||
2. Add Teams API:
|
||||
|
||||
- Search and select `Skype and Teams Tenant Admin API` API in **APIs my organization uses**.
|
||||
|
||||

|
||||
|
||||
- Select `application_access` permission and click on `Add permissions`.
|
||||
|
||||

|
||||
|
||||
3. Click on `Grant admin consent for <your-tenant-name>` to grant admin consent.
|
||||
|
||||

|
||||
|
||||
The final result of permission assignment should be this:
|
||||
|
||||

|
||||
|
||||
???+ warning
|
||||
Remember that if the user is newly created, you need to sign in with that account first, as Microsoft will prompt you to change the password. If you don’t complete this step, user authentication will fail because Microsoft marks the initial password as expired.
|
||||
|
||||
---
|
||||
|
||||
#### If using user authentication (Currently Prowler Cloud only supports this method)
|
||||
|
||||
1. Search and select:
|
||||
|
||||
- `User.Read`
|
||||
|
||||

|
||||
|
||||
6. After adding all the permissions, click on `Grant admin consent`
|
||||
2. Click `Add permissions`, then **grant admin consent**
|
||||
|
||||

|
||||
|
||||
@@ -147,37 +212,32 @@ Follow these steps to assign the permissions:
|
||||
|
||||

|
||||
|
||||
---
|
||||
3. Assign **required roles** to your **user**
|
||||
|
||||
### Assign required roles to your user
|
||||
Assign one of the following roles to your User:
|
||||
|
||||
Assign one of the following roles to your User:
|
||||
- `Global Reader` (recommended): this allows you to read all roles needed.
|
||||
- `Exchange Administrator` and `Teams Administrator`: user needs both roles but with this [roles](https://learn.microsoft.com/en-us/exchange/permissions-exo/permissions-exo#microsoft-365-permissions-in-exchange-online) you can access to the same information as a Global Reader (here you only read so that's why we recomend that role).
|
||||
|
||||
- `Global Reader` (recommended): this allows you to read all roles needed.
|
||||
- `Exchange Administrator` and `Teams Administrator`: user needs both roles but with this [roles](https://learn.microsoft.com/en-us/exchange/permissions-exo/permissions-exo#microsoft-365-permissions-in-exchange-online) you can access to the same information as a Global Reader (here you only read so that's why we recomend that role).
|
||||
Follow these steps to assign the role:
|
||||
|
||||
Follow these steps to assign the role:
|
||||
1. Go to Users > All Users > Click on the email for the user you will use
|
||||
|
||||
1. Go to Users > All Users > Click on the email for the user you will use
|
||||

|
||||
|
||||

|
||||
2. Click `Assigned Roles`
|
||||
|
||||
2. Click `Assigned Roles`
|
||||

|
||||
|
||||

|
||||
3. Click on `Add assignments`, then search and select:
|
||||
|
||||
3. Click on `Add assignments`, then search and select:
|
||||
- `Global Reader` This is the recommended, if you want to use the others just search for them
|
||||
|
||||
- `Global Reader` This is the recommended, if you want to use the others just search for them
|
||||

|
||||
|
||||

|
||||
4. Click on next, then assign the role as `Active`, and click on `Assign` to grant admin consent
|
||||
|
||||
4. Click on next, then assign the role as `Active`, and click on `Assign` to grant admin consent
|
||||
|
||||

|
||||
|
||||
???+ warning
|
||||
Remember that if the user is newly created, you need to sign in with that account first, as Microsoft will prompt you to change the password. If you don’t complete this step, user authentication will fail because Microsoft marks the initial password as expired.
|
||||

|
||||
|
||||
---
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 196 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 349 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 69 KiB |
@@ -36,6 +36,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
|
||||
- IaC provider [(#7852)](https://github.com/prowler-cloud/prowler/pull/7852)
|
||||
- Azure Databricks service integration for Azure provider, including the `databricks_workspace_vnet_injection_enabled` check [(#8008)](https://github.com/prowler-cloud/prowler/pull/8008)
|
||||
- Azure Databricks check `databricks_workspace_cmk_encryption_enabled` to ensure workspaces use customer-managed keys (CMK) for encryption at rest [(#8017)](https://github.com/prowler-cloud/prowler/pull/8017)
|
||||
- Appication auth for PowerShell in M365 provider [(#7992)](https://github.com/prowler-cloud/prowler/pull/7992)
|
||||
- Add `storage_account_default_to_entra_authorization_enabled` check for Azure provider. [(#7981)](https://github.com/prowler-cloud/prowler/pull/7981)
|
||||
- Improve overview page from Prowler Dashboard [(#8118)](https://github.com/prowler-cloud/prowler/pull/8118)
|
||||
- `keyvault_ensure_public_network_access_disabled` check for Azure provider. [(#8072)](https://github.com/prowler-cloud/prowler/pull/8072)
|
||||
|
||||
@@ -114,6 +114,18 @@ class M365BaseException(ProwlerException):
|
||||
"message": "The provided User does not belong to the specified tenant.",
|
||||
"remediation": "Check the User email domain and ensure it belongs to the specified tenant.",
|
||||
},
|
||||
(6027, "M365GraphConnectionError"): {
|
||||
"message": "Failed to establish connection to Microsoft Graph API.",
|
||||
"remediation": "Check your Microsoft Application credentials and ensure the app has proper permissions.",
|
||||
},
|
||||
(6028, "M365TeamsConnectionError"): {
|
||||
"message": "Failed to establish connection to Microsoft Teams API.",
|
||||
"remediation": "Ensure the application has proper permission granted to access Microsoft Teams.",
|
||||
},
|
||||
(6029, "M365ExchangeConnectionError"): {
|
||||
"message": "Failed to establish connection to Exchange Online API.",
|
||||
"remediation": "Ensure the application has proper permission granted to access Exchange Online.",
|
||||
},
|
||||
}
|
||||
|
||||
def __init__(self, code, file=None, original_exception=None, message=None):
|
||||
@@ -324,3 +336,24 @@ class M365UserNotBelongingToTenantError(M365CredentialsError):
|
||||
super().__init__(
|
||||
6026, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class M365GraphConnectionError(M365CredentialsError):
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
6027, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class M365TeamsConnectionError(M365CredentialsError):
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
6028, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
|
||||
class M365ExchangeConnectionError(M365CredentialsError):
|
||||
def __init__(self, file=None, original_exception=None, message=None):
|
||||
super().__init__(
|
||||
6029, file=file, original_exception=original_exception, message=message
|
||||
)
|
||||
|
||||
@@ -6,6 +6,9 @@ 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,
|
||||
M365UserNotBelongingToTenantError,
|
||||
)
|
||||
from prowler.providers.m365.models import M365Credentials, M365IdentityInfo
|
||||
@@ -65,21 +68,33 @@ class M365PowerShell(PowerShellSession):
|
||||
The credentials are sanitized to prevent command injection and
|
||||
stored securely in the PowerShell session.
|
||||
"""
|
||||
# User Auth (Will be deprecated in September 2025)
|
||||
if credentials.user and credentials.passwd:
|
||||
credentials.encrypted_passwd = self.encrypt_password(credentials.passwd)
|
||||
|
||||
credentials.encrypted_passwd = self.encrypt_password(credentials.passwd)
|
||||
# Sanitize user and password
|
||||
sanitized_user = self.sanitize(credentials.user)
|
||||
sanitized_encrypted_passwd = self.sanitize(credentials.encrypted_passwd)
|
||||
|
||||
# Sanitize user and password
|
||||
sanitized_user = self.sanitize(credentials.user)
|
||||
sanitized_encrypted_passwd = self.sanitize(credentials.encrypted_passwd)
|
||||
|
||||
# Securely convert encrypted password to SecureString
|
||||
self.execute(f'$user = "{sanitized_user}"')
|
||||
self.execute(
|
||||
f'$secureString = "{sanitized_encrypted_passwd}" | ConvertTo-SecureString'
|
||||
)
|
||||
self.execute(
|
||||
"$credential = New-Object System.Management.Automation.PSCredential ($user, $secureString)"
|
||||
)
|
||||
# Securely convert encrypted password to SecureString
|
||||
self.execute(f'$user = "{sanitized_user}"')
|
||||
self.execute(
|
||||
f'$secureString = "{sanitized_encrypted_passwd}" | ConvertTo-SecureString'
|
||||
)
|
||||
self.execute(
|
||||
"$credential = New-Object System.Management.Automation.PSCredential ($user, $secureString)"
|
||||
)
|
||||
else:
|
||||
# Application Auth
|
||||
self.execute(f'$clientID = "{credentials.client_id}"')
|
||||
self.execute(f'$clientSecret = "{credentials.client_secret}"')
|
||||
self.execute(f'$tenantID = "{credentials.tenant_id}"')
|
||||
self.execute(
|
||||
'$graphtokenBody = @{ Grant_Type = "client_credentials"; Scope = "https://graph.microsoft.com/.default"; Client_Id = $clientID; Client_Secret = $clientSecret }'
|
||||
)
|
||||
self.execute(
|
||||
'$graphToken = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$tenantID/oauth2/v2.0/token" -Method POST -Body $graphtokenBody | Select-Object -ExpandProperty Access_Token'
|
||||
)
|
||||
|
||||
def encrypt_password(self, password: str) -> str:
|
||||
"""
|
||||
@@ -129,46 +144,151 @@ class M365PowerShell(PowerShellSession):
|
||||
Returns:
|
||||
bool: True if credentials are valid and authentication succeeds, False otherwise.
|
||||
"""
|
||||
self.execute(
|
||||
f'$securePassword = "{credentials.encrypted_passwd}" | ConvertTo-SecureString' # encrypted password already sanitized
|
||||
)
|
||||
self.execute(
|
||||
f'$credential = New-Object System.Management.Automation.PSCredential("{self.sanitize(credentials.user)}", $securePassword)'
|
||||
)
|
||||
if credentials.user and credentials.passwd:
|
||||
self.execute(
|
||||
f'$securePassword = "{credentials.encrypted_passwd}" | ConvertTo-SecureString' # encrypted password already sanitized
|
||||
)
|
||||
self.execute(
|
||||
f'$credential = New-Object System.Management.Automation.PSCredential("{self.sanitize(credentials.user)}", $securePassword)'
|
||||
)
|
||||
|
||||
# Validate user belongs to tenant
|
||||
user_domain = credentials.user.split("@")[1]
|
||||
if not any(
|
||||
user_domain.endswith(domain)
|
||||
for domain in self.tenant_identity.tenant_domains
|
||||
):
|
||||
raise M365UserNotBelongingToTenantError(
|
||||
user_domain = credentials.user.split("@")[1]
|
||||
if not any(
|
||||
user_domain.endswith(domain)
|
||||
for domain in self.tenant_identity.tenant_domains
|
||||
):
|
||||
raise M365UserNotBelongingToTenantError(
|
||||
file=os.path.basename(__file__),
|
||||
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')}")
|
||||
|
||||
return True
|
||||
|
||||
else:
|
||||
# Test Microsoft Graph connection
|
||||
try:
|
||||
logger.info("Testing Microsoft Graph connection...")
|
||||
self.test_graph_connection()
|
||||
logger.info("Microsoft Graph connection successful")
|
||||
except Exception as e:
|
||||
logger.error(f"Microsoft Graph connection failed: {e}")
|
||||
raise M365GraphConnectionError(
|
||||
file=os.path.basename(__file__),
|
||||
original_exception=e,
|
||||
message="Check your Microsoft Application credentials and ensure the app has proper permissions",
|
||||
)
|
||||
|
||||
# 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:
|
||||
if self.execute("Write-Output $graphToken") == "":
|
||||
raise M365GraphConnectionError(
|
||||
file=os.path.basename(__file__),
|
||||
message="Microsoft Graph token is empty or invalid.",
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Microsoft Graph connection failed: {e}")
|
||||
raise M365GraphConnectionError(
|
||||
file=os.path.basename(__file__),
|
||||
message=f"The user domain {user_domain} does not match any of the tenant domains: {', '.join(self.tenant_identity.tenant_domains)}",
|
||||
original_exception=e,
|
||||
message=f"Failed to connect to Microsoft Graph API: {str(e)}",
|
||||
)
|
||||
|
||||
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."
|
||||
def test_teams_connection(self) -> bool:
|
||||
"""Test Microsoft Teams API connection and raise exception if it fails."""
|
||||
try:
|
||||
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'
|
||||
)
|
||||
if self.execute("Write-Output $teamsToken") == "":
|
||||
raise M365TeamsConnectionError(
|
||||
file=os.path.basename(__file__),
|
||||
message="Microsoft Teams token is empty or invalid.",
|
||||
)
|
||||
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)}",
|
||||
)
|
||||
|
||||
if "access_token" not in result:
|
||||
raise Exception(f"MsGraph Error {result.get('error_description')}")
|
||||
|
||||
return True
|
||||
def test_exchange_connection(self) -> bool:
|
||||
"""Test Exchange Online API connection and raise exception if it fails."""
|
||||
try:
|
||||
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"'
|
||||
)
|
||||
if self.execute("Write-Output $exchangeToken") == "":
|
||||
raise M365ExchangeConnectionError(
|
||||
file=os.path.basename(__file__),
|
||||
message="Exchange Online token is empty or invalid.",
|
||||
)
|
||||
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)}",
|
||||
)
|
||||
|
||||
def connect_microsoft_teams(self) -> dict:
|
||||
"""
|
||||
@@ -182,7 +302,18 @@ class M365PowerShell(PowerShellSession):
|
||||
Note:
|
||||
This method requires the Microsoft Teams PowerShell module to be installed.
|
||||
"""
|
||||
return self.execute("Connect-MicrosoftTeams -Credential $credential")
|
||||
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")'
|
||||
)
|
||||
|
||||
def get_teams_settings(self) -> dict:
|
||||
"""
|
||||
@@ -276,7 +407,18 @@ class M365PowerShell(PowerShellSession):
|
||||
Note:
|
||||
This method requires the Exchange Online PowerShell module to be installed.
|
||||
"""
|
||||
return self.execute("Connect-ExchangeOnline -Credential $credential")
|
||||
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"'
|
||||
)
|
||||
|
||||
def get_audit_log_config(self) -> dict:
|
||||
"""
|
||||
@@ -758,25 +900,20 @@ def initialize_m365_powershell_modules():
|
||||
bool: True if all modules were successfully initialized, False otherwise
|
||||
"""
|
||||
|
||||
REQUIRED_MODULES = [
|
||||
"ExchangeOnlineManagement",
|
||||
"MicrosoftTeams",
|
||||
]
|
||||
REQUIRED_MODULES = ["ExchangeOnlineManagement", "MicrosoftTeams", "MSAL.PS"]
|
||||
|
||||
pwsh = PowerShellSession()
|
||||
try:
|
||||
for module in REQUIRED_MODULES:
|
||||
try:
|
||||
# Check if module is already installed
|
||||
result = pwsh.execute(
|
||||
f"Get-Module -ListAvailable -Name {module}", timeout=5
|
||||
)
|
||||
result = pwsh.execute(f"Get-Module -ListAvailable {module}", timeout=5)
|
||||
|
||||
# Install module if not installed
|
||||
if not result:
|
||||
install_result = pwsh.execute(
|
||||
f'Install-Module -Name "{module}" -Force -AllowClobber -Scope CurrentUser',
|
||||
timeout=30,
|
||||
f'Install-Module "{module}" -Force -AllowClobber -Scope CurrentUser',
|
||||
timeout=60,
|
||||
)
|
||||
if install_result:
|
||||
logger.warning(
|
||||
@@ -786,7 +923,7 @@ def initialize_m365_powershell_modules():
|
||||
logger.info(f"Successfully installed module {module}")
|
||||
|
||||
# Import module
|
||||
pwsh.execute(f'Import-Module -Name "{module}" -Force', timeout=1)
|
||||
pwsh.execute(f'Import-Module "{module}" -Force', timeout=1)
|
||||
|
||||
except Exception as error:
|
||||
logger.error(f"Failed to initialize module {module}: {str(error)}")
|
||||
|
||||
@@ -43,9 +43,7 @@ from prowler.providers.m365.exceptions.exceptions import (
|
||||
M365NotTenantIdButClientIdAndClientSecretError,
|
||||
M365NotValidClientIdError,
|
||||
M365NotValidClientSecretError,
|
||||
M365NotValidPasswordError,
|
||||
M365NotValidTenantIdError,
|
||||
M365NotValidUserError,
|
||||
M365SetUpRegionConfigError,
|
||||
M365SetUpSessionError,
|
||||
M365TenantIdAndClientIdNotBelongingToClientSecretError,
|
||||
@@ -172,7 +170,7 @@ class M365Provider(Provider):
|
||||
|
||||
# Get the dict from the static credentials
|
||||
m365_credentials = None
|
||||
if tenant_id and client_id and client_secret and user and password:
|
||||
if tenant_id and client_id and client_secret:
|
||||
m365_credentials = self.validate_static_credentials(
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
@@ -204,6 +202,7 @@ class M365Provider(Provider):
|
||||
# Set up PowerShell session credentials
|
||||
self._credentials = self.setup_powershell(
|
||||
env_auth=env_auth,
|
||||
sp_env_auth=sp_env_auth,
|
||||
m365_credentials=m365_credentials,
|
||||
identity=self.identity,
|
||||
init_modules=init_modules,
|
||||
@@ -378,6 +377,7 @@ class M365Provider(Provider):
|
||||
@staticmethod
|
||||
def setup_powershell(
|
||||
env_auth: bool = False,
|
||||
sp_env_auth: bool = False,
|
||||
m365_credentials: dict = {},
|
||||
identity: M365IdentityInfo = None,
|
||||
init_modules: bool = False,
|
||||
@@ -392,12 +392,13 @@ class M365Provider(Provider):
|
||||
If env_auth is True, retrieves from environment variables.
|
||||
If False, returns empty credentials.
|
||||
"""
|
||||
logger.info("M365 provider: Setting up PowerShell session...")
|
||||
credentials = None
|
||||
|
||||
if m365_credentials:
|
||||
credentials = M365Credentials(
|
||||
user=m365_credentials.get("user", ""),
|
||||
passwd=m365_credentials.get("password", ""),
|
||||
user=m365_credentials.get("user", None),
|
||||
passwd=m365_credentials.get("password", None),
|
||||
client_id=m365_credentials.get("client_id", ""),
|
||||
client_secret=m365_credentials.get("client_secret", ""),
|
||||
tenant_id=m365_credentials.get("tenant_id", ""),
|
||||
@@ -418,6 +419,7 @@ class M365Provider(Provider):
|
||||
file=os.path.basename(__file__),
|
||||
message="Missing M365_USER or M365_PASSWORD environment variables required for credentials authentication.",
|
||||
)
|
||||
|
||||
credentials = M365Credentials(
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
@@ -427,14 +429,25 @@ class M365Provider(Provider):
|
||||
passwd=m365_password,
|
||||
)
|
||||
|
||||
elif sp_env_auth:
|
||||
client_id = getenv("AZURE_CLIENT_ID")
|
||||
client_secret = getenv("AZURE_CLIENT_SECRET")
|
||||
tenant_id = getenv("AZURE_TENANT_ID")
|
||||
credentials = M365Credentials(
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
tenant_id=tenant_id,
|
||||
tenant_domains=identity.tenant_domains,
|
||||
)
|
||||
|
||||
if credentials:
|
||||
if identity:
|
||||
if identity and credentials.user:
|
||||
identity.user = credentials.user
|
||||
test_session = M365PowerShell(credentials, identity)
|
||||
try:
|
||||
if init_modules:
|
||||
initialize_m365_powershell_modules()
|
||||
if test_session.test_credentials(credentials):
|
||||
if init_modules:
|
||||
initialize_m365_powershell_modules()
|
||||
return credentials
|
||||
raise M365UserCredentialsError(
|
||||
file=os.path.basename(__file__),
|
||||
@@ -523,6 +536,8 @@ class M365Provider(Provider):
|
||||
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:
|
||||
@@ -688,8 +703,8 @@ class M365Provider(Provider):
|
||||
tenant_id=tenant_id,
|
||||
client_id=client_id,
|
||||
client_secret=client_secret,
|
||||
user="user",
|
||||
password="password",
|
||||
user=None,
|
||||
password=None,
|
||||
)
|
||||
else:
|
||||
m365_credentials = M365Provider.validate_static_credentials(
|
||||
@@ -739,17 +754,13 @@ class M365Provider(Provider):
|
||||
logger.info("M365 provider: Identity retrieved successfully")
|
||||
|
||||
# Set up PowerShell credentials
|
||||
if user and password:
|
||||
M365Provider.setup_powershell(
|
||||
env_auth,
|
||||
m365_credentials,
|
||||
identity,
|
||||
)
|
||||
logger.info("M365 provider: Connection to PowerShell successful")
|
||||
else:
|
||||
logger.info(
|
||||
"M365 provider: Connection to PowerShell has not been requested"
|
||||
)
|
||||
M365Provider.setup_powershell(
|
||||
env_auth,
|
||||
sp_env_auth,
|
||||
m365_credentials,
|
||||
identity,
|
||||
)
|
||||
logger.info("M365 provider: Connection to PowerShell successful")
|
||||
|
||||
return Connection(is_connected=True)
|
||||
|
||||
@@ -1030,20 +1041,6 @@ class M365Provider(Provider):
|
||||
message="The provided Client Secret is not valid.",
|
||||
)
|
||||
|
||||
# Validate the User
|
||||
if not user:
|
||||
raise M365NotValidUserError(
|
||||
file=os.path.basename(__file__),
|
||||
message="The provided User is not valid.",
|
||||
)
|
||||
|
||||
# Validate the Password
|
||||
if not password:
|
||||
raise M365NotValidPasswordError(
|
||||
file=os.path.basename(__file__),
|
||||
message="The provided Password is not valid.",
|
||||
)
|
||||
|
||||
try:
|
||||
M365Provider.verify_client(tenant_id, client_id, client_secret)
|
||||
return {
|
||||
|
||||
@@ -24,9 +24,9 @@ class M365RegionConfig(BaseModel):
|
||||
|
||||
|
||||
class M365Credentials(BaseModel):
|
||||
user: str = ""
|
||||
passwd: str = ""
|
||||
encrypted_passwd: str = ""
|
||||
user: Optional[str] = None
|
||||
passwd: Optional[str] = None
|
||||
encrypted_passwd: Optional[str] = None
|
||||
client_id: str = ""
|
||||
client_secret: str = ""
|
||||
tenant_id: str = ""
|
||||
|
||||
@@ -4,6 +4,9 @@ import pytest
|
||||
|
||||
from prowler.lib.powershell.powershell import PowerShellSession
|
||||
from prowler.providers.m365.exceptions.exceptions import (
|
||||
M365ExchangeConnectionError,
|
||||
M365GraphConnectionError,
|
||||
M365TeamsConnectionError,
|
||||
M365UserNotBelongingToTenantError,
|
||||
)
|
||||
from prowler.providers.m365.lib.powershell.m365_powershell import M365PowerShell
|
||||
@@ -97,7 +100,7 @@ class Testm365PowerShell:
|
||||
session.init_credential(credentials)
|
||||
|
||||
# Verify encrypt_password was called
|
||||
session.encrypt_password.assert_called_once_with(credentials.passwd)
|
||||
session.encrypt_password.assert_any_call(credentials.passwd)
|
||||
|
||||
# Verify execute was called with the correct commands
|
||||
session.execute.assert_any_call(f'$user = "{credentials.user}"')
|
||||
@@ -167,6 +170,37 @@ class Testm365PowerShell:
|
||||
)
|
||||
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")
|
||||
|
||||
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")
|
||||
def test_test_credentials_user_not_belonging_to_tenant(self, mock_msal, mock_popen):
|
||||
@@ -514,12 +548,13 @@ class Testm365PowerShell:
|
||||
# Verify successful initialization
|
||||
assert result is True
|
||||
# Verify that execute was called for each module
|
||||
assert mock_execute_obj.call_count == 6 # 2 modules * 3 commands each
|
||||
assert mock_execute_obj.call_count == 9 # 3 modules * 3 commands each
|
||||
# Verify success messages were logged
|
||||
mock_info.assert_any_call(
|
||||
"Successfully installed module ExchangeOnlineManagement"
|
||||
)
|
||||
mock_info.assert_any_call("Successfully installed module MicrosoftTeams")
|
||||
mock_info.assert_any_call("Successfully installed module MSAL.PS")
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_initialize_m365_powershell_modules_failure(self, mock_popen):
|
||||
@@ -582,11 +617,12 @@ class Testm365PowerShell:
|
||||
main()
|
||||
|
||||
# Verify all info messages were logged in the correct order
|
||||
assert mock_info.call_count == 3
|
||||
assert mock_info.call_count == 4
|
||||
mock_info.assert_has_calls(
|
||||
[
|
||||
call("Successfully installed module ExchangeOnlineManagement"),
|
||||
call("Successfully installed module MicrosoftTeams"),
|
||||
call("Successfully installed module MSAL.PS"),
|
||||
call("M365 PowerShell modules initialized successfully"),
|
||||
]
|
||||
)
|
||||
@@ -629,6 +665,260 @@ class Testm365PowerShell:
|
||||
# Verify no info messages were logged
|
||||
mock_info.assert_not_called()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_test_graph_connection_success(self, mock_popen):
|
||||
"""Test test_graph_connection when token is valid"""
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
credentials = M365Credentials(user="test@example.com", passwd="test_password")
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="Application",
|
||||
tenant_id="test_tenant",
|
||||
tenant_domain="example.com",
|
||||
tenant_domains=["example.com"],
|
||||
location="test_location",
|
||||
)
|
||||
session = M365PowerShell(credentials, identity)
|
||||
|
||||
# Mock execute to return a valid token
|
||||
session.execute = MagicMock(return_value="valid_token")
|
||||
|
||||
result = session.test_graph_connection()
|
||||
|
||||
assert result is True
|
||||
session.execute.assert_called_once_with("Write-Output $graphToken")
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_test_graph_connection_empty_token(self, mock_popen):
|
||||
"""Test test_graph_connection when token is empty"""
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
credentials = M365Credentials(user="test@example.com", passwd="test_password")
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="Application",
|
||||
tenant_id="test_tenant",
|
||||
tenant_domain="example.com",
|
||||
tenant_domains=["example.com"],
|
||||
location="test_location",
|
||||
)
|
||||
session = M365PowerShell(credentials, identity)
|
||||
|
||||
# Mock execute to return empty token
|
||||
session.execute = MagicMock(return_value="")
|
||||
|
||||
with pytest.raises(M365GraphConnectionError) as exc_info:
|
||||
session.test_graph_connection()
|
||||
|
||||
assert "Microsoft Graph token is empty or invalid" in str(exc_info.value)
|
||||
session.execute.assert_called_once_with("Write-Output $graphToken")
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_test_graph_connection_exception(self, mock_popen):
|
||||
"""Test test_graph_connection when an exception occurs"""
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
credentials = M365Credentials(user="test@example.com", passwd="test_password")
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="Application",
|
||||
tenant_id="test_tenant",
|
||||
tenant_domain="example.com",
|
||||
tenant_domains=["example.com"],
|
||||
location="test_location",
|
||||
)
|
||||
session = M365PowerShell(credentials, identity)
|
||||
|
||||
# Mock execute to raise an exception
|
||||
session.execute = MagicMock(side_effect=Exception("PowerShell error"))
|
||||
|
||||
with pytest.raises(M365GraphConnectionError) as exc_info:
|
||||
session.test_graph_connection()
|
||||
|
||||
assert "Failed to connect to Microsoft Graph API: PowerShell error" in str(
|
||||
exc_info.value
|
||||
)
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_test_teams_connection_success(self, mock_popen):
|
||||
"""Test test_teams_connection when token is valid"""
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
credentials = M365Credentials(user="test@example.com", passwd="test_password")
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="Application",
|
||||
tenant_id="test_tenant",
|
||||
tenant_domain="example.com",
|
||||
tenant_domains=["example.com"],
|
||||
location="test_location",
|
||||
)
|
||||
session = M365PowerShell(credentials, identity)
|
||||
|
||||
# Mock execute to return valid responses
|
||||
def mock_execute(command, *args, **kwargs):
|
||||
if "Write-Output $teamsToken" in command:
|
||||
return "valid_teams_token"
|
||||
return None
|
||||
|
||||
session.execute = MagicMock(side_effect=mock_execute)
|
||||
|
||||
result = session.test_teams_connection()
|
||||
|
||||
assert result is True
|
||||
# Verify all expected PowerShell commands were called
|
||||
assert session.execute.call_count == 3
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_test_teams_connection_empty_token(self, mock_popen):
|
||||
"""Test test_teams_connection when token is empty"""
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
credentials = M365Credentials(user="test@example.com", passwd="test_password")
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="Application",
|
||||
tenant_id="test_tenant",
|
||||
tenant_domain="example.com",
|
||||
tenant_domains=["example.com"],
|
||||
location="test_location",
|
||||
)
|
||||
session = M365PowerShell(credentials, identity)
|
||||
|
||||
# Mock execute to return empty token when checking
|
||||
def mock_execute(command, *args, **kwargs):
|
||||
if "Write-Output $teamsToken" in command:
|
||||
return ""
|
||||
return None
|
||||
|
||||
session.execute = MagicMock(side_effect=mock_execute)
|
||||
|
||||
with pytest.raises(M365TeamsConnectionError) as exc_info:
|
||||
session.test_teams_connection()
|
||||
|
||||
assert "Microsoft Teams token is empty or invalid" in str(exc_info.value)
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_test_teams_connection_exception(self, mock_popen):
|
||||
"""Test test_teams_connection when an exception occurs"""
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
credentials = M365Credentials(user="test@example.com", passwd="test_password")
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="Application",
|
||||
tenant_id="test_tenant",
|
||||
tenant_domain="example.com",
|
||||
tenant_domains=["example.com"],
|
||||
location="test_location",
|
||||
)
|
||||
session = M365PowerShell(credentials, identity)
|
||||
|
||||
# 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()
|
||||
|
||||
assert "Failed to connect to Microsoft Teams API: Teams API error" in str(
|
||||
exc_info.value
|
||||
)
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_test_exchange_connection_success(self, mock_popen):
|
||||
"""Test test_exchange_connection when token is valid"""
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
credentials = M365Credentials(user="test@example.com", passwd="test_password")
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="Application",
|
||||
tenant_id="test_tenant",
|
||||
tenant_domain="example.com",
|
||||
tenant_domains=["example.com"],
|
||||
location="test_location",
|
||||
)
|
||||
session = M365PowerShell(credentials, identity)
|
||||
|
||||
# Mock execute to return valid responses
|
||||
def mock_execute(command, *args, **kwargs):
|
||||
if "Write-Output $exchangeToken" in command:
|
||||
return "valid_exchange_token"
|
||||
return None
|
||||
|
||||
session.execute = MagicMock(side_effect=mock_execute)
|
||||
|
||||
result = session.test_exchange_connection()
|
||||
|
||||
assert result is True
|
||||
# Verify all expected PowerShell commands were called
|
||||
assert session.execute.call_count == 3
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_test_exchange_connection_empty_token(self, mock_popen):
|
||||
"""Test test_exchange_connection when token is empty"""
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
credentials = M365Credentials(user="test@example.com", passwd="test_password")
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="Application",
|
||||
tenant_id="test_tenant",
|
||||
tenant_domain="example.com",
|
||||
tenant_domains=["example.com"],
|
||||
location="test_location",
|
||||
)
|
||||
session = M365PowerShell(credentials, identity)
|
||||
|
||||
# Mock execute to return empty token when checking
|
||||
def mock_execute(command, *args, **kwargs):
|
||||
if "Write-Output $exchangeToken" in command:
|
||||
return ""
|
||||
return None
|
||||
|
||||
session.execute = MagicMock(side_effect=mock_execute)
|
||||
|
||||
with pytest.raises(M365ExchangeConnectionError) as exc_info:
|
||||
session.test_exchange_connection()
|
||||
|
||||
assert "Exchange Online token is empty or invalid" in str(exc_info.value)
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_test_exchange_connection_exception(self, mock_popen):
|
||||
"""Test test_exchange_connection when an exception occurs"""
|
||||
mock_process = MagicMock()
|
||||
mock_popen.return_value = mock_process
|
||||
credentials = M365Credentials(user="test@example.com", passwd="test_password")
|
||||
identity = M365IdentityInfo(
|
||||
identity_id="test_id",
|
||||
identity_type="Application",
|
||||
tenant_id="test_tenant",
|
||||
tenant_domain="example.com",
|
||||
tenant_domains=["example.com"],
|
||||
location="test_location",
|
||||
)
|
||||
session = M365PowerShell(credentials, identity)
|
||||
|
||||
# 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()
|
||||
|
||||
assert "Failed to connect to Exchange Online API: Exchange API error" in str(
|
||||
exc_info.value
|
||||
)
|
||||
session.close()
|
||||
|
||||
@patch("subprocess.Popen")
|
||||
def test_encrypt_password(self, mock_popen):
|
||||
credentials = M365Credentials(user="test@example.com", passwd="test_password")
|
||||
|
||||
@@ -76,6 +76,16 @@ class TestM365Provider:
|
||||
location=LOCATION,
|
||||
),
|
||||
),
|
||||
patch(
|
||||
"prowler.providers.m365.m365_provider.M365Provider.setup_powershell",
|
||||
return_value=M365Credentials(
|
||||
client_id=CLIENT_ID,
|
||||
tenant_id=TENANT_ID,
|
||||
client_secret=CLIENT_SECRET,
|
||||
user="",
|
||||
passwd="",
|
||||
),
|
||||
),
|
||||
):
|
||||
m365_provider = M365Provider(
|
||||
sp_env_auth=True,
|
||||
@@ -490,27 +500,6 @@ class TestM365Provider:
|
||||
assert result.user == credentials_dict["user"]
|
||||
assert result.passwd == credentials_dict["password"]
|
||||
|
||||
def test_setup_powershell_invalid_env_credentials(self):
|
||||
credentials = None
|
||||
|
||||
with patch(
|
||||
"prowler.providers.m365.lib.powershell.m365_powershell.M365PowerShell"
|
||||
) as mock_powershell:
|
||||
mock_session = MagicMock()
|
||||
mock_session.test_credentials.return_value = False
|
||||
mock_powershell.return_value = mock_session
|
||||
|
||||
with pytest.raises(M365MissingEnvironmentCredentialsError) as exc_info:
|
||||
M365Provider.setup_powershell(
|
||||
env_auth=True, m365_credentials=credentials
|
||||
)
|
||||
|
||||
assert (
|
||||
"Missing M365_USER or M365_PASSWORD environment variables required for credentials authentication"
|
||||
in str(exc_info.value)
|
||||
)
|
||||
mock_session.test_credentials.assert_not_called()
|
||||
|
||||
def test_test_connection_user_not_belonging_to_tenant(
|
||||
self,
|
||||
):
|
||||
@@ -572,28 +561,6 @@ class TestM365Provider:
|
||||
)
|
||||
assert "The provided Client Secret is not valid." in str(exception.value)
|
||||
|
||||
def test_validate_static_credentials_missing_user(self):
|
||||
with pytest.raises(M365NotValidUserError) as exception:
|
||||
M365Provider.validate_static_credentials(
|
||||
tenant_id="12345678-1234-5678-1234-567812345678",
|
||||
client_id="12345678-1234-5678-1234-567812345678",
|
||||
client_secret="test_secret",
|
||||
user="",
|
||||
password="test_password",
|
||||
)
|
||||
assert "The provided User is not valid." in str(exception.value)
|
||||
|
||||
def test_validate_static_credentials_missing_password(self):
|
||||
with pytest.raises(M365NotValidPasswordError) as exception:
|
||||
M365Provider.validate_static_credentials(
|
||||
tenant_id="12345678-1234-5678-1234-567812345678",
|
||||
client_id="12345678-1234-5678-1234-567812345678",
|
||||
client_secret="test_secret",
|
||||
user="test@example.com",
|
||||
password="",
|
||||
)
|
||||
assert "The provided Password is not valid." in str(exception.value)
|
||||
|
||||
def test_validate_arguments_missing_env_credentials(self):
|
||||
with pytest.raises(M365MissingEnvironmentCredentialsError) as exception:
|
||||
M365Provider.validate_arguments(
|
||||
|
||||