Merge branch 'master' into feat/PROWLER-940-stage-2-a-image-provider-api-v3

This commit is contained in:
Andoni A. committed 2026-02-20 12:43:26 +01:00
commit 04ac302af2
67 files changed
+5011 -166

No files matched your search

+1 -2
View File
@@ -61,9 +61,8 @@ jobs:
- name: Safety
if: steps.check-changes.outputs.any_changed == 'true'
run: poetry run safety check --ignore 79023,79027,84420
run: poetry run safety check --ignore 79023,79027
# TODO: 79023 & 79027 knack ReDoS until `azure-cli-core` (via `cartography`) allows `knack` >=0.13.0
# TODO: 84420 from `azure-core`, that we need fix alltogether with `azure-cli-core` and `knack`
- name: Vulture
if: steps.check-changes.outputs.any_changed == 'true'
+1 -2
View File
@@ -120,8 +120,7 @@ repos:
description: "Safety is a tool that checks your installed dependencies for known security vulnerabilities"
# TODO: Botocore needs urllib3 1.X so we need to ignore these vulnerabilities 77744,77745. Remove this once we upgrade to urllib3 2.X
# TODO: 79023 & 79027 knack ReDoS until `azure-cli-core` (via `cartography`) allows `knack` >=0.13.0
# TODO: 84420 from `azure-core`, that we need fix alltogether with `azure-cli-core` and `knack`
entry: bash -c 'safety check --ignore 70612,66963,74429,76352,76353,77744,77745,79023,79027,84420'
entry: bash -c 'safety check --ignore 70612,66963,74429,76352,76353,77744,77745,79023,79027'
language: system
- id: vulture
+2
View File
@@ -24,6 +24,7 @@ All notable changes to the **Prowler API** are documented in this file.
- Attack Paths: Remove legacy per-scan `graph_database` and `is_graph_database_deleted` fields from AttackPathsScan model [(#10077)](https://github.com/prowler-cloud/prowler/pull/10077)
- Attack Paths: Add `graph_data_ready` field to decouple query availability from scan state [(#10089)](https://github.com/prowler-cloud/prowler/pull/10089)
- AI agent guidelines with TDD and testing skills references [(#9925)](https://github.com/prowler-cloud/prowler/pull/9925)
- Attack Paths: Upgrade Cartography from fork 0.126.1 to upstream 0.129.0 and Neo4j driver from 5.x to 6.x [(#10110)](https://github.com/prowler-cloud/prowler/pull/10110)
### 🐞 Fixed
@@ -32,6 +33,7 @@ All notable changes to the **Prowler API** are documented in this file.
### 🔐 Security
- Bump `Pillow` to 12.1.1 (CVE-2021-25289) [(#10027)](https://github.com/prowler-cloud/prowler/pull/10027)
- Remove safety ignore for CVE-2026-21226 (84420), fixed via `azure-core` 1.38.x [(#10110)](https://github.com/prowler-cloud/prowler/pull/10110)
---
+7
View File
@@ -24,6 +24,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
python3-dev \
&& rm -rf /var/lib/apt/lists/*
# Cartography depends on `dockerfile` which has no pre-built arm64 wheel and requires Go to compile
# hadolint ignore=DL3008
RUN if [ "$(uname -m)" = "aarch64" ]; then \
apt-get update && apt-get install -y --no-install-recommends golang-go \
&& rm -rf /var/lib/apt/lists/* ; \
fi
# Install PowerShell
RUN ARCH=$(uname -m) && \
if [ "$ARCH" = "x86_64" ]; then \
+448 -47
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -36,8 +36,8 @@ dependencies = [
"drf-simple-apikey (==2.2.1)",
"matplotlib (>=3.10.6,<4.0.0)",
"reportlab (>=4.4.4,<5.0.0)",
"neo4j (<6.0.0)",
"cartography @ git+https://github.com/prowler-cloud/cartography@0.126.1",
"neo4j (>=6.0.0,<7.0.0)",
"cartography (==0.129.0)",
"gevent (>=25.9.1,<26.0.0)",
"werkzeug (>=3.1.4)",
"sqlparse (>=0.5.4)",
@@ -39,12 +39,6 @@ class RetryableSession:
def run(self, *args: Any, **kwargs: Any) -> Any:
return self._call_with_retry("run", *args, **kwargs)
def write_transaction(self, *args: Any, **kwargs: Any) -> Any:
return self._call_with_retry("write_transaction", *args, **kwargs)
def read_transaction(self, *args: Any, **kwargs: Any) -> Any:
return self._call_with_retry("read_transaction", *args, **kwargs)
def execute_write(self, *args: Any, **kwargs: Any) -> Any:
return self._call_with_retry("execute_write", *args, **kwargs)
@@ -10,7 +10,7 @@ Prowler's Image provider enables comprehensive container image security scanning
* **Trivy integration:** Prowler leverages [Trivy](https://trivy.dev/) to scan container images for vulnerabilities, secrets, misconfigurations, and license issues.
* **Trivy required:** Trivy must be installed and available in the system PATH before running any scan.
* **Authentication:** No registry authentication is required for public images. For private registries, configure Docker credentials via `docker login` before scanning.
* **Authentication:** No registry authentication is required for public images. For private registries, credentials can be provided via environment variables or manual `docker login`.
* **Output formats:** Results are output in the same formats as other Prowler providers (CSV, JSON, HTML, etc.).
## Prowler CLI
@@ -173,25 +173,147 @@ prowler image -I large-image:latest --timeout 10m
The timeout accepts values in seconds (`s`), minutes (`m`), or hours (`h`). Default: `5m`.
### Authentication for Private Registries
### Registry Scan Mode
The Image provider relies on Trivy for registry authentication. To scan images from private registries, configure Docker credentials before running the scan:
Registry Scan Mode enumerates and scans all images from an OCI-compatible registry, Docker Hub namespace, or Amazon ECR registry. To activate it, use the `--registry` flag with the registry URL:
```bash
# Log in to a private registry
docker login myregistry.io
prowler image --registry myregistry.io
```
#### Discover Available Images
To list all repositories and tags available in the registry without running a scan, use the `--registry-list` flag. This is useful for discovering image names and tags before building filter regexes:
```bash
prowler image --registry myregistry.io --registry-list
```
Example output:
```text
Registry: myregistry.io (3 repositories, 8 images)
api-service (2 tags)
latest, v3.1
hub-scanner (3 tags)
latest, v1.0, v2.0
web-frontend (3 tags)
latest, v1.0, v2.0
```
Filters can be combined with `--registry-list` to preview the results before scanning:
```bash
prowler image --registry myregistry.io --registry-list --image-filter "api.*"
```
#### Filter Repositories
To filter repositories by name during enumeration, use the `--image-filter` flag with a Python regex pattern (matched via `re.search`):
```bash
# Scan only repositories starting with "prod/"
prowler image --registry myregistry.io --image-filter "^prod/"
```
#### Filter Tags
To filter tags during enumeration, use the `--tag-filter` flag with a Python regex pattern:
```bash
# Scan only semantic version tags
prowler image --registry myregistry.io --tag-filter "^v\d+\.\d+\.\d+$"
```
Both filters can be combined:
```bash
prowler image --registry myregistry.io --image-filter "^prod/" --tag-filter "^(latest|v\d+)"
```
#### Limit the Number of Images
To prevent accidentally scanning a large number of images, use the `--max-images` flag. The scan aborts if the discovered image count exceeds the limit:
```bash
prowler image --registry myregistry.io --max-images 10
```
Setting `--max-images` to `0` (default) disables the limit.
<Note>
When `--registry-list` is active, the `--max-images` limit is not enforced because no scan is performed.
</Note>
#### Skip TLS Verification
To connect to registries with self-signed certificates, use the `--registry-insecure` flag:
```bash
prowler image --registry internal-registry.local --registry-insecure
```
<Warning>
Skipping TLS verification disables certificate validation for registry connections. Use this flag only for trusted internal registries with self-signed certificates.
</Warning>
#### Supported Registries
Registry Scan Mode supports the following registry types:
* **OCI-compatible registries:** Any registry implementing the OCI Distribution Specification (e.g., Harbor, GitLab Container Registry, GitHub Container Registry).
* **Docker Hub:** Specify a namespace with `--registry docker.io/{org_or_user}`. Public namespaces can be scanned without credentials; authenticated access is used automatically when `REGISTRY_USERNAME` and `REGISTRY_PASSWORD` are set.
* **Amazon ECR:** Use the full ECR endpoint URL (e.g., `123456789.dkr.ecr.us-east-1.amazonaws.com`). Authentication is handled via AWS credentials.
### Authentication for Private Registries
To scan images from private registries, the Image provider supports three authentication methods. Prowler uses the first available method in this priority order:
#### 1. Basic Authentication (Environment Variables)
To authenticate with a username and password, set the `REGISTRY_USERNAME` and `REGISTRY_PASSWORD` environment variables. Prowler automatically runs `docker login`, pulls the image, and performs a `docker logout` after the scan completes:
```bash
export REGISTRY_USERNAME="myuser"
export REGISTRY_PASSWORD="mypassword"
# Then scan the image
prowler image -I myregistry.io/myapp:v1.0
```
Trivy automatically uses credentials from Docker's credential store (`~/.docker/config.json`).
Both variables must be set for this method to activate. Prowler handles the full lifecycle — login, pull, scan, and cleanup — without any manual Docker commands.
#### 2. Token-Based Authentication
To authenticate using a registry token (such as a bearer or OAuth2 token), set the `REGISTRY_TOKEN` environment variable. Prowler passes the token directly to Trivy:
```bash
export REGISTRY_TOKEN="my-registry-token"
prowler image -I myregistry.io/myapp:v1.0
```
This method is useful for registries that support token-based access without requiring a username and password.
#### 3. Manual Docker Login (Fallback)
If no environment variables are set, Prowler relies on existing credentials in Docker's credential store (`~/.docker/config.json`). To configure credentials manually before scanning:
```bash
docker login myregistry.io
prowler image -I myregistry.io/myapp:v1.0
```
<Note>
When basic authentication is active (method 1), Prowler automatically logs out from all authenticated registries after the scan completes. Manual `docker login` sessions (method 3) are not affected by this cleanup.
</Note>
### Troubleshooting Common Scan Errors
The Image provider categorizes common Trivy errors with actionable guidance:
* **Authentication failure (401/403):** Registry credentials are missing or invalid. Run `docker login` for the target registry and retry the scan.
* **Authentication failure (401/403):** Registry credentials are missing or invalid. Verify the `REGISTRY_USERNAME`/`REGISTRY_PASSWORD` or `REGISTRY_TOKEN` environment variables, or run `docker login` for the target registry and retry the scan.
* **Image not found (404):** The specified image name, tag, or registry is incorrect. Verify the image reference exists and is accessible.
* **Rate limited (429):** The container registry is throttling requests. Wait before retrying, or authenticate to increase rate limits.
* **Network issue:** Trivy cannot reach the registry due to connectivity problems. Check network access, DNS resolution, and firewall rules.
@@ -41,8 +41,12 @@ When using service principal authentication, add these **Application Permissions
- `AuditLog.Read.All`: Required for Entra service.
- `Directory.Read.All`: Required for all services.
- `OnPremDirectorySynchronization.Read.All`: Required for `entra_seamless_sso_disabled` check (hybrid deployments).
- `Policy.Read.All`: Required for all services.
- `SecurityIdentitiesHealth.Read.All`: Required for `defenderidentity_health_issues_no_open` check.
- `SecurityIdentitiesSensors.Read.All`: Required for `defenderidentity_health_issues_no_open` check.
- `SharePointTenantSettings.Read.All`: Required for SharePoint service.
- `ThreatHunting.Read.All`: Required for Defender XDR checks (`defenderxdr_endpoint_privileged_user_exposed_credentials`, `defenderxdr_critical_asset_management_pending_approvals`).
**External API Permissions:**
@@ -105,7 +109,10 @@ Browser and Azure CLI authentication methods limit scanning capabilities to chec
- `AuditLog.Read.All`: Required for Entra service
- `Directory.Read.All`: Required for all services
- `OnPremDirectorySynchronization.Read.All`: Required for `entra_seamless_sso_disabled` check (hybrid deployments)
- `Policy.Read.All`: Required for all services
- `SecurityIdentitiesHealth.Read.All`: Required for `defenderidentity_health_issues_no_open` check
- `SecurityIdentitiesSensors.Read.All`: Required for `defenderidentity_health_issues_no_open` check
- `SharePointTenantSettings.Read.All`: Required for SharePoint service
![Permission Screenshots](/images/providers/directory-permission.png)
Generated
+98 -85
View File
@@ -2002,43 +2002,49 @@ toml = ["tomli ; python_full_version <= \"3.11.0a6\""]
[[package]]
name = "cryptography"
version = "44.0.1"
version = "44.0.3"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
optional = false
python-versions = "!=3.9.0,!=3.9.1,>=3.7"
groups = ["main", "dev"]
files = [
{file = "cryptography-44.0.1-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:bf688f615c29bfe9dfc44312ca470989279f0e94bb9f631f85e3459af8efc009"},
{file = "cryptography-44.0.1-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd7c7e2d71d908dc0f8d2027e1604102140d84b155e658c20e8ad1304317691f"},
{file = "cryptography-44.0.1-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:887143b9ff6bad2b7570da75a7fe8bbf5f65276365ac259a5d2d5147a73775f2"},
{file = "cryptography-44.0.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:322eb03ecc62784536bc173f1483e76747aafeb69c8728df48537eb431cd1911"},
{file = "cryptography-44.0.1-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:21377472ca4ada2906bc313168c9dc7b1d7ca417b63c1c3011d0c74b7de9ae69"},
{file = "cryptography-44.0.1-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:df978682c1504fc93b3209de21aeabf2375cb1571d4e61907b3e7a2540e83026"},
{file = "cryptography-44.0.1-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:eb3889330f2a4a148abead555399ec9a32b13b7c8ba969b72d8e500eb7ef84cd"},
{file = "cryptography-44.0.1-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:8e6a85a93d0642bd774460a86513c5d9d80b5c002ca9693e63f6e540f1815ed0"},
{file = "cryptography-44.0.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6f76fdd6fd048576a04c5210d53aa04ca34d2ed63336d4abd306d0cbe298fddf"},
{file = "cryptography-44.0.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6c8acf6f3d1f47acb2248ec3ea261171a671f3d9428e34ad0357148d492c7864"},
{file = "cryptography-44.0.1-cp37-abi3-win32.whl", hash = "sha256:24979e9f2040c953a94bf3c6782e67795a4c260734e5264dceea65c8f4bae64a"},
{file = "cryptography-44.0.1-cp37-abi3-win_amd64.whl", hash = "sha256:fd0ee90072861e276b0ff08bd627abec29e32a53b2be44e41dbcdf87cbee2b00"},
{file = "cryptography-44.0.1-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:a2d8a7045e1ab9b9f803f0d9531ead85f90c5f2859e653b61497228b18452008"},
{file = "cryptography-44.0.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8272f257cf1cbd3f2e120f14c68bff2b6bdfcc157fafdee84a1b795efd72862"},
{file = "cryptography-44.0.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1e8d181e90a777b63f3f0caa836844a1182f1f265687fac2115fcf245f5fbec3"},
{file = "cryptography-44.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:436df4f203482f41aad60ed1813811ac4ab102765ecae7a2bbb1dbb66dcff5a7"},
{file = "cryptography-44.0.1-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4f422e8c6a28cf8b7f883eb790695d6d45b0c385a2583073f3cec434cc705e1a"},
{file = "cryptography-44.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:72198e2b5925155497a5a3e8c216c7fb3e64c16ccee11f0e7da272fa93b35c4c"},
{file = "cryptography-44.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a46a89ad3e6176223b632056f321bc7de36b9f9b93b2cc1cccf935a3849dc62"},
{file = "cryptography-44.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:53f23339864b617a3dfc2b0ac8d5c432625c80014c25caac9082314e9de56f41"},
{file = "cryptography-44.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:888fcc3fce0c888785a4876ca55f9f43787f4c5c1cc1e2e0da71ad481ff82c5b"},
{file = "cryptography-44.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:00918d859aa4e57db8299607086f793fa7813ae2ff5a4637e318a25ef82730f7"},
{file = "cryptography-44.0.1-cp39-abi3-win32.whl", hash = "sha256:9b336599e2cb77b1008cb2ac264b290803ec5e8e89d618a5e978ff5eb6f715d9"},
{file = "cryptography-44.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:e403f7f766ded778ecdb790da786b418a9f2394f36e8cc8b796cc056ab05f44f"},
{file = "cryptography-44.0.1-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:1f9a92144fa0c877117e9748c74501bea842f93d21ee00b0cf922846d9d0b183"},
{file = "cryptography-44.0.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:610a83540765a8d8ce0f351ce42e26e53e1f774a6efb71eb1b41eb01d01c3d12"},
{file = "cryptography-44.0.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:5fed5cd6102bb4eb843e3315d2bf25fede494509bddadb81e03a859c1bc17b83"},
{file = "cryptography-44.0.1-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f4daefc971c2d1f82f03097dc6f216744a6cd2ac0f04c68fb935ea2ba2a0d420"},
{file = "cryptography-44.0.1-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94f99f2b943b354a5b6307d7e8d19f5c423a794462bde2bf310c770ba052b1c4"},
{file = "cryptography-44.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d9c5b9f698a83c8bd71e0f4d3f9f839ef244798e5ffe96febfa9714717db7af7"},
{file = "cryptography-44.0.1.tar.gz", hash = "sha256:f51f5705ab27898afda1aaa430f34ad90dc117421057782022edf0600bec5f14"},
{file = "cryptography-44.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:962bc30480a08d133e631e8dfd4783ab71cc9e33d5d7c1e192f0b7c06397bb88"},
{file = "cryptography-44.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4ffc61e8f3bf5b60346d89cd3d37231019c17a081208dfbbd6e1605ba03fa137"},
{file = "cryptography-44.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58968d331425a6f9eedcee087f77fd3c927c88f55368f43ff7e0a19891f2642c"},
{file = "cryptography-44.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e28d62e59a4dbd1d22e747f57d4f00c459af22181f0b2f787ea83f5a876d7c76"},
{file = "cryptography-44.0.3-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af653022a0c25ef2e3ffb2c673a50e5a0d02fecc41608f4954176f1933b12359"},
{file = "cryptography-44.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:157f1f3b8d941c2bd8f3ffee0af9b049c9665c39d3da9db2dc338feca5e98a43"},
{file = "cryptography-44.0.3-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:c6cd67722619e4d55fdb42ead64ed8843d64638e9c07f4011163e46bc512cf01"},
{file = "cryptography-44.0.3-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:b424563394c369a804ecbee9b06dfb34997f19d00b3518e39f83a5642618397d"},
{file = "cryptography-44.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c91fc8e8fd78af553f98bc7f2a1d8db977334e4eea302a4bfd75b9461c2d8904"},
{file = "cryptography-44.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:25cd194c39fa5a0aa4169125ee27d1172097857b27109a45fadc59653ec06f44"},
{file = "cryptography-44.0.3-cp37-abi3-win32.whl", hash = "sha256:3be3f649d91cb182c3a6bd336de8b61a0a71965bd13d1a04a0e15b39c3d5809d"},
{file = "cryptography-44.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:3883076d5c4cc56dbef0b898a74eb6992fdac29a7b9013870b34efe4ddb39a0d"},
{file = "cryptography-44.0.3-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:5639c2b16764c6f76eedf722dbad9a0914960d3489c0cc38694ddf9464f1bb2f"},
{file = "cryptography-44.0.3-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3ffef566ac88f75967d7abd852ed5f182da252d23fac11b4766da3957766759"},
{file = "cryptography-44.0.3-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:192ed30fac1728f7587c6f4613c29c584abdc565d7417c13904708db10206645"},
{file = "cryptography-44.0.3-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7d5fe7195c27c32a64955740b949070f21cba664604291c298518d2e255931d2"},
{file = "cryptography-44.0.3-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3f07943aa4d7dad689e3bb1638ddc4944cc5e0921e3c227486daae0e31a05e54"},
{file = "cryptography-44.0.3-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cb90f60e03d563ca2445099edf605c16ed1d5b15182d21831f58460c48bffb93"},
{file = "cryptography-44.0.3-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ab0b005721cc0039e885ac3503825661bd9810b15d4f374e473f8c89b7d5460c"},
{file = "cryptography-44.0.3-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3bb0847e6363c037df8f6ede57d88eaf3410ca2267fb12275370a76f85786a6f"},
{file = "cryptography-44.0.3-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b0cc66c74c797e1db750aaa842ad5b8b78e14805a9b5d1348dc603612d3e3ff5"},
{file = "cryptography-44.0.3-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6866df152b581f9429020320e5eb9794c8780e90f7ccb021940d7f50ee00ae0b"},
{file = "cryptography-44.0.3-cp39-abi3-win32.whl", hash = "sha256:c138abae3a12a94c75c10499f1cbae81294a6f983b3af066390adee73f433028"},
{file = "cryptography-44.0.3-cp39-abi3-win_amd64.whl", hash = "sha256:5d186f32e52e66994dce4f766884bcb9c68b8da62d61d9d215bfe5fb56d21334"},
{file = "cryptography-44.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:cad399780053fb383dc067475135e41c9fe7d901a97dd5d9c5dfb5611afc0d7d"},
{file = "cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:21a83f6f35b9cc656d71b5de8d519f566df01e660ac2578805ab245ffd8523f8"},
{file = "cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fc3c9babc1e1faefd62704bb46a69f359a9819eb0292e40df3fb6e3574715cd4"},
{file = "cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:e909df4053064a97f1e6565153ff8bb389af12c5c8d29c343308760890560aff"},
{file = "cryptography-44.0.3-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:dad80b45c22e05b259e33ddd458e9e2ba099c86ccf4e88db7bbab4b747b18d06"},
{file = "cryptography-44.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:479d92908277bed6e1a1c69b277734a7771c2b78633c224445b5c60a9f4bc1d9"},
{file = "cryptography-44.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:896530bc9107b226f265effa7ef3f21270f18a2026bc09fed1ebd7b66ddf6375"},
{file = "cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:9b4d4a5dbee05a2c390bf212e78b99434efec37b17a4bff42f50285c5c8c9647"},
{file = "cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:02f55fb4f8b79c1221b0961488eaae21015b69b210e18c386b69de182ebb1259"},
{file = "cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:dd3db61b8fe5be220eee484a17233287d0be6932d056cf5738225b9c05ef4fff"},
{file = "cryptography-44.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:978631ec51a6bbc0b7e58f23b68a8ce9e5f09721940933e9c217068388789fe5"},
{file = "cryptography-44.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:5d20cc348cca3a8aa7312f42ab953a56e15323800ca3ab0706b8cd452a3a056c"},
{file = "cryptography-44.0.3.tar.gz", hash = "sha256:fe19d8bc5536a91a24a8133328880a41831b6c5df54599a8417b62fe015d3053"},
]
[package.dependencies]
@@ -2051,7 +2057,7 @@ nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_version >= \"3.8\""]
pep8test = ["check-sdist ; python_version >= \"3.8\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"]
sdist = ["build (>=1.0.0)"]
ssh = ["bcrypt (>=3.1.5)"]
test = ["certifi (>=2024)", "cryptography-vectors (==44.0.1)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
test = ["certifi (>=2024)", "cryptography-vectors (==44.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
test-randomorder = ["pytest-randomly"]
[[package]]
@@ -2133,6 +2139,18 @@ files = [
{file = "decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360"},
]
[[package]]
name = "defusedxml"
version = "0.7.1"
description = "XML bomb protection for Python stdlib modules"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
groups = ["main"]
files = [
{file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"},
{file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"},
]
[[package]]
name = "deprecated"
version = "1.2.18"
@@ -4017,46 +4035,6 @@ files = [
{file = "nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe"},
]
[[package]]
name = "netifaces"
version = "0.11.0"
description = "Portable network interface information."
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "netifaces-0.11.0-cp27-cp27m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eb4813b77d5df99903af4757ce980a98c4d702bbcb81f32a0b305a1537bdf0b1"},
{file = "netifaces-0.11.0-cp27-cp27m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:5f9ca13babe4d845e400921973f6165a4c2f9f3379c7abfc7478160e25d196a4"},
{file = "netifaces-0.11.0-cp27-cp27m-win32.whl", hash = "sha256:7dbb71ea26d304e78ccccf6faccef71bb27ea35e259fb883cfd7fd7b4f17ecb1"},
{file = "netifaces-0.11.0-cp27-cp27m-win_amd64.whl", hash = "sha256:0f6133ac02521270d9f7c490f0c8c60638ff4aec8338efeff10a1b51506abe85"},
{file = "netifaces-0.11.0-cp27-cp27mu-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:08e3f102a59f9eaef70948340aeb6c89bd09734e0dca0f3b82720305729f63ea"},
{file = "netifaces-0.11.0-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c03fb2d4ef4e393f2e6ffc6376410a22a3544f164b336b3a355226653e5efd89"},
{file = "netifaces-0.11.0-cp34-cp34m-win32.whl", hash = "sha256:73ff21559675150d31deea8f1f8d7e9a9a7e4688732a94d71327082f517fc6b4"},
{file = "netifaces-0.11.0-cp35-cp35m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:815eafdf8b8f2e61370afc6add6194bd5a7252ae44c667e96c4c1ecf418811e4"},
{file = "netifaces-0.11.0-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:50721858c935a76b83dd0dd1ab472cad0a3ef540a1408057624604002fcfb45b"},
{file = "netifaces-0.11.0-cp35-cp35m-win32.whl", hash = "sha256:c9a3a47cd3aaeb71e93e681d9816c56406ed755b9442e981b07e3618fb71d2ac"},
{file = "netifaces-0.11.0-cp36-cp36m-macosx_10_15_x86_64.whl", hash = "sha256:aab1dbfdc55086c789f0eb37affccf47b895b98d490738b81f3b2360100426be"},
{file = "netifaces-0.11.0-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c37a1ca83825bc6f54dddf5277e9c65dec2f1b4d0ba44b8fd42bc30c91aa6ea1"},
{file = "netifaces-0.11.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:28f4bf3a1361ab3ed93c5ef360c8b7d4a4ae060176a3529e72e5e4ffc4afd8b0"},
{file = "netifaces-0.11.0-cp36-cp36m-win32.whl", hash = "sha256:2650beee182fed66617e18474b943e72e52f10a24dc8cac1db36c41ee9c041b7"},
{file = "netifaces-0.11.0-cp36-cp36m-win_amd64.whl", hash = "sha256:cb925e1ca024d6f9b4f9b01d83215fd00fe69d095d0255ff3f64bffda74025c8"},
{file = "netifaces-0.11.0-cp37-cp37m-macosx_10_15_x86_64.whl", hash = "sha256:84e4d2e6973eccc52778735befc01638498781ce0e39aa2044ccfd2385c03246"},
{file = "netifaces-0.11.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:18917fbbdcb2d4f897153c5ddbb56b31fa6dd7c3fa9608b7e3c3a663df8206b5"},
{file = "netifaces-0.11.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:48324183af7f1bc44f5f197f3dad54a809ad1ef0c78baee2c88f16a5de02c4c9"},
{file = "netifaces-0.11.0-cp37-cp37m-win32.whl", hash = "sha256:8f7da24eab0d4184715d96208b38d373fd15c37b0dafb74756c638bd619ba150"},
{file = "netifaces-0.11.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2479bb4bb50968089a7c045f24d120f37026d7e802ec134c4490eae994c729b5"},
{file = "netifaces-0.11.0-cp38-cp38-macosx_10_15_x86_64.whl", hash = "sha256:3ecb3f37c31d5d51d2a4d935cfa81c9bc956687c6f5237021b36d6fdc2815b2c"},
{file = "netifaces-0.11.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:96c0fe9696398253f93482c84814f0e7290eee0bfec11563bd07d80d701280c3"},
{file = "netifaces-0.11.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:c92ff9ac7c2282009fe0dcb67ee3cd17978cffbe0c8f4b471c00fe4325c9b4d4"},
{file = "netifaces-0.11.0-cp38-cp38-win32.whl", hash = "sha256:d07b01c51b0b6ceb0f09fc48ec58debd99d2c8430b09e56651addeaf5de48048"},
{file = "netifaces-0.11.0-cp38-cp38-win_amd64.whl", hash = "sha256:469fc61034f3daf095e02f9f1bbac07927b826c76b745207287bc594884cfd05"},
{file = "netifaces-0.11.0-cp39-cp39-macosx_10_15_x86_64.whl", hash = "sha256:5be83986100ed1fdfa78f11ccff9e4757297735ac17391b95e17e74335c2047d"},
{file = "netifaces-0.11.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:54ff6624eb95b8a07e79aa8817288659af174e954cca24cdb0daeeddfc03c4ff"},
{file = "netifaces-0.11.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:841aa21110a20dc1621e3dd9f922c64ca64dd1eb213c47267a2c324d823f6c8f"},
{file = "netifaces-0.11.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:e76c7f351e0444721e85f975ae92718e21c1f361bda946d60a214061de1f00a1"},
{file = "netifaces-0.11.0.tar.gz", hash = "sha256:043a79146eb2907edf439899f262b3dfe41717d34124298ed281139a8b93ca32"},
]
[[package]]
name = "networkx"
version = "3.2.1"
@@ -4290,14 +4268,14 @@ openapi-schema-validator = ">=0.6.0,<0.7.0"
[[package]]
name = "openstacksdk"
version = "4.0.1"
version = "4.2.0"
description = "An SDK for building applications to work with OpenStack"
optional = false
python-versions = ">=3.8"
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "openstacksdk-4.0.1-py3-none-any.whl", hash = "sha256:d63187a006fff7c1de1486c9e2e1073a787af402620c3c0ed0cf5291225998ac"},
{file = "openstacksdk-4.0.1.tar.gz", hash = "sha256:19faa1d5e6a78a2c1dc06a171e65e776ba82e9df23e1d08586225dc5ade9fc63"},
{file = "openstacksdk-4.2.0-py3-none-any.whl", hash = "sha256:238be0fa5d9899872b00787ab38e84f92fd6dc87525fde0965dadcdc12196dc6"},
{file = "openstacksdk-4.2.0.tar.gz", hash = "sha256:5cb9450dcce8054a2caf89d8be9e55057ddfa219a954e781032241eb29280445"},
]
[package.dependencies]
@@ -4308,10 +4286,10 @@ iso8601 = ">=0.1.11"
jmespath = ">=0.9.0"
jsonpatch = ">=1.16,<1.20 || >1.20"
keystoneauth1 = ">=3.18.0"
netifaces = ">=0.10.4"
os-service-types = ">=1.7.0"
pbr = ">=2.0.0,<2.1.0 || >2.1.0"
platformdirs = ">=3"
psutil = ">=3.2.2"
PyYAML = ">=3.13"
requestsexceptions = ">=1.2.0"
@@ -4768,6 +4746,41 @@ files = [
{file = "protobuf-6.31.1.tar.gz", hash = "sha256:d8cac4c982f0b957a4dc73a80e2ea24fab08e679c0de9deb835f4a12d69aca9a"},
]
[[package]]
name = "psutil"
version = "7.2.2"
description = "Cross-platform lib for process and system monitoring."
optional = false
python-versions = ">=3.6"
groups = ["main"]
files = [
{file = "psutil-7.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:2edccc433cbfa046b980b0df0171cd25bcaeb3a68fe9022db0979e7aa74a826b"},
{file = "psutil-7.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78c8603dcd9a04c7364f1a3e670cea95d51ee865e4efb3556a3a63adef958ea"},
{file = "psutil-7.2.2-cp313-cp313t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1a571f2330c966c62aeda00dd24620425d4b0cc86881c89861fbc04549e5dc63"},
{file = "psutil-7.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:917e891983ca3c1887b4ef36447b1e0873e70c933afc831c6b6da078ba474312"},
{file = "psutil-7.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:ab486563df44c17f5173621c7b198955bd6b613fb87c71c161f827d3fb149a9b"},
{file = "psutil-7.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:ae0aefdd8796a7737eccea863f80f81e468a1e4cf14d926bd9b6f5f2d5f90ca9"},
{file = "psutil-7.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:eed63d3b4d62449571547b60578c5b2c4bcccc5387148db46e0c2313dad0ee00"},
{file = "psutil-7.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7b6d09433a10592ce39b13d7be5a54fbac1d1228ed29abc880fb23df7cb694c9"},
{file = "psutil-7.2.2-cp314-cp314t-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fa4ecf83bcdf6e6c8f4449aff98eefb5d0604bf88cb883d7da3d8d2d909546a"},
{file = "psutil-7.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e452c464a02e7dc7822a05d25db4cde564444a67e58539a00f929c51eddda0cf"},
{file = "psutil-7.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c7663d4e37f13e884d13994247449e9f8f574bc4655d509c3b95e9ec9e2b9dc1"},
{file = "psutil-7.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:11fe5a4f613759764e79c65cf11ebdf26e33d6dd34336f8a337aa2996d71c841"},
{file = "psutil-7.2.2-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:ed0cace939114f62738d808fdcecd4c869222507e266e574799e9c0faa17d486"},
{file = "psutil-7.2.2-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:1a7b04c10f32cc88ab39cbf606e117fd74721c831c98a27dc04578deb0c16979"},
{file = "psutil-7.2.2-cp36-abi3-manylinux2010_x86_64.manylinux_2_12_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:076a2d2f923fd4821644f5ba89f059523da90dc9014e85f8e45a5774ca5bc6f9"},
{file = "psutil-7.2.2-cp36-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0726cecd84f9474419d67252add4ac0cd9811b04d61123054b9fb6f57df6e9e"},
{file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd04ef36b4a6d599bbdb225dd1d3f51e00105f6d48a28f006da7f9822f2606d8"},
{file = "psutil-7.2.2-cp36-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b58fabe35e80b264a4e3bb23e6b96f9e45a3df7fb7eed419ac0e5947c61e47cc"},
{file = "psutil-7.2.2-cp37-abi3-win_amd64.whl", hash = "sha256:eb7e81434c8d223ec4a219b5fc1c47d0417b12be7ea866e24fb5ad6e84b3d988"},
{file = "psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee"},
{file = "psutil-7.2.2.tar.gz", hash = "sha256:0746f5f8d406af344fd547f1c8daa5f5c33dbc293bb8d6a16d80b4bb88f59372"},
]
[package.extras]
dev = ["abi3audit", "black", "check-manifest", "colorama ; os_name == \"nt\"", "coverage", "packaging", "psleak", "pylint", "pyperf", "pypinfo", "pyreadline3 ; os_name == \"nt\"", "pytest", "pytest-cov", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "requests", "rstcheck", "ruff", "setuptools", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "validate-pyproject[all]", "virtualenv", "vulture", "wheel", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""]
test = ["psleak", "pytest", "pytest-instafail", "pytest-xdist", "pywin32 ; os_name == \"nt\" and implementation_name != \"pypy\"", "setuptools", "wheel ; os_name == \"nt\" and implementation_name != \"pypy\"", "wmi ; os_name == \"nt\" and implementation_name != \"pypy\""]
[[package]]
name = "py-iam-expand"
version = "0.1.0"
@@ -4785,20 +4798,20 @@ iamdata = ">=0.1.202504091"
[[package]]
name = "py-ocsf-models"
version = "0.5.0"
version = "0.8.1"
description = "This is a Python implementation of the OCSF models. The models are used to represent the data of the OCSF Schema defined in https://schema.ocsf.io/."
optional = false
python-versions = "<3.14,>3.9.1"
python-versions = "<3.15,>3.9.1"
groups = ["main"]
files = [
{file = "py_ocsf_models-0.5.0-py3-none-any.whl", hash = "sha256:7933253f56782c04c412d976796db429577810b951fe4195351794500b5962d8"},
{file = "py_ocsf_models-0.5.0.tar.gz", hash = "sha256:bf05e955809d1ec3ab1007e4a4b2a8a0afa74b6e744ea8ffbf386e46b3af0a76"},
{file = "py_ocsf_models-0.8.1-py3-none-any.whl", hash = "sha256:061eb446c4171534c09a8b37f5a9d2a2fe9f87c5db32edbd1182446bc5fd097e"},
{file = "py_ocsf_models-0.8.1.tar.gz", hash = "sha256:c9045237857f951e073c9f9d1f57954c90d86875b469260725292d47f7a7d73c"},
]
[package.dependencies]
cryptography = "44.0.1"
cryptography = ">=44.0.3,<47"
email-validator = "2.2.0"
pydantic = ">=2.9.2,<3.0.0"
pydantic = ">=2.12.0,<3.0.0"
[[package]]
name = "py-partiql-parser"
@@ -6880,4 +6893,4 @@ files = [
[metadata]
lock-version = "2.1"
python-versions = ">3.9.1,<3.13"
content-hash = "f9ff21ae57caa3ddcd27f3753c29c1b3be2966709baed52e1bbc24e7bdc33f3c"
content-hash = "509440ff7a10d735686d330ac032f824fc92cf2dbacc66371e688ae1dd25dc2f"
+6
View File
@@ -6,6 +6,8 @@ All notable changes to the **Prowler SDK** are documented in this file.
### 🚀 Added
- `entra_app_registration_no_unused_privileged_permissions` check for m365 provider [(#10080)](https://github.com/prowler-cloud/prowler/pull/10080)
- `defenderidentity_health_issues_no_open` check for M365 provider [(#10087)](https://github.com/prowler-cloud/prowler/pull/10087)
- `organization_verified_badge` check for GitHub provider [(#10033)](https://github.com/prowler-cloud/prowler/pull/10033)
- OpenStack provider `clouds_yaml_content` parameter for API integration [(#10003)](https://github.com/prowler-cloud/prowler/pull/10003)
- `defender_safe_attachments_policy_enabled` check for M365 provider [(#9833)](https://github.com/prowler-cloud/prowler/pull/9833)
@@ -20,6 +22,10 @@ All notable changes to the **Prowler SDK** are documented in this file.
- OpenStack compute 7 new checks [(#9944)](https://github.com/prowler-cloud/prowler/pull/9944)
- CSA CCM 4.0 for the Alibaba Cloud provider [(#10061)](https://github.com/prowler-cloud/prowler/pull/10061)
- ECS Exec (ECS-006) privilege escalation detection via `ecs:ExecuteCommand` + `ecs:DescribeTasks` [(#10066)](https://github.com/prowler-cloud/prowler/pull/10066)
- `defenderxdr_endpoint_privileged_user_exposed_credentials` check for M365 provider [(#10084)](https://github.com/prowler-cloud/prowler/pull/10084)
- `defenderxdr_critical_asset_management_pending_approvals` check for M365 provider [(#10085)](https://github.com/prowler-cloud/prowler/pull/10085)
- `entra_seamless_sso_disabled` check for m365 provider [(#10086)](https://github.com/prowler-cloud/prowler/pull/10086)
- Registry scan mode for `image` provider: enumerate and scan all images from OCI standard, Docker Hub, and ECR [(#9985)](https://github.com/prowler-cloud/prowler/pull/9985)
- Add file descriptor limits (`ulimits`) to Docker Compose worker services to prevent `Too many open files` errors [(#10107)](https://github.com/prowler-cloud/prowler/pull/10107)
### 🔄 Changed
@@ -983,6 +983,7 @@
"Id": "5.1.5.1",
"Description": "Control when end users and group owners are allowed to grant consent to applications, and when they will be required to request administrator review and approval. Allowing users to grant apps access to data helps them acquire useful applications and be productive but can represent a risk in some situations if it's not monitored and controlled carefully.",
"Checks": [
"entra_app_registration_no_unused_privileged_permissions",
"entra_policy_restricts_user_consent_for_apps"
],
"Attributes": [
@@ -1215,6 +1215,7 @@
"Id": "5.1.5.1",
"Description": "User consent to apps accessing company data on their behalf allows users to grant permissions to applications without administrator involvement. The recommended state is Do not allow user consent.",
"Checks": [
"entra_app_registration_no_unused_privileged_permissions",
"entra_policy_restricts_user_consent_for_apps"
],
"Attributes": [
+28 -10
View File
@@ -117,6 +117,8 @@
"defender_malware_policy_notifications_internal_users_malware_enabled",
"defender_safelinks_policy_enabled",
"defender_zap_for_teams_enabled",
"defenderxdr_endpoint_privileged_user_exposed_credentials",
"defender_identity_health_issues_no_open",
"entra_admin_users_phishing_resistant_mfa_enabled",
"entra_identity_protection_sign_in_risk_enabled",
"entra_identity_protection_user_risk_enabled"
@@ -154,6 +156,7 @@
}
],
"Checks": [
"defenderxdr_critical_asset_management_pending_approvals",
"sharepoint_external_sharing_managed",
"exchange_external_email_tagging_enabled"
]
@@ -199,7 +202,8 @@
"admincenter_users_admins_reduced_license_footprint",
"entra_admin_portals_access_restriction",
"entra_admin_users_phishing_resistant_mfa_enabled",
"entra_policy_guest_users_access_restrictions"
"entra_policy_guest_users_access_restrictions",
"entra_seamless_sso_disabled"
]
},
{
@@ -215,7 +219,8 @@
}
],
"Checks": [
"admincenter_settings_password_never_expire"
"admincenter_settings_password_never_expire",
"entra_seamless_sso_disabled"
]
},
{
@@ -231,11 +236,13 @@
}
],
"Checks": [
"defenderxdr_endpoint_privileged_user_exposed_credentials",
"entra_admin_users_sign_in_frequency_enabled",
"entra_admin_users_mfa_enabled",
"entra_admin_users_sign_in_frequency_enabled",
"entra_legacy_authentication_blocked",
"entra_managed_device_required_for_authentication",
"entra_seamless_sso_disabled",
"entra_users_mfa_enabled",
"exchange_organization_modern_authentication_enabled",
"exchange_transport_config_smtp_auth_disabled",
@@ -255,11 +262,12 @@
}
],
"Checks": [
"sharepoint_external_sharing_restricted",
"sharepoint_external_sharing_managed",
"sharepoint_guest_sharing_restricted",
"entra_admin_portals_access_restriction",
"entra_app_registration_no_unused_privileged_permissions",
"entra_policy_guest_users_access_restrictions",
"entra_admin_portals_access_restriction"
"sharepoint_external_sharing_managed",
"sharepoint_external_sharing_restricted",
"sharepoint_guest_sharing_restricted"
]
},
{
@@ -448,6 +456,7 @@
"defender_antispam_outbound_policy_configured",
"defender_antispam_outbound_policy_forwarding_disabled",
"defender_antispam_policy_inbound_no_allowed_domains",
"defenderxdr_critical_asset_management_pending_approvals",
"defender_chat_report_policy_configured",
"defender_malware_policy_common_attachments_filter_enabled",
"defender_malware_policy_comprehensive_attachments_filter_applied",
@@ -602,6 +611,7 @@
}
],
"Checks": [
"defenderxdr_endpoint_privileged_user_exposed_credentials",
"entra_managed_device_required_for_authentication",
"entra_users_mfa_enabled",
"entra_managed_device_required_for_mfa_registration",
@@ -629,14 +639,17 @@
"admincenter_users_admins_reduced_license_footprint",
"admincenter_users_between_two_and_four_global_admins",
"defender_antispam_outbound_policy_configured",
"defenderxdr_endpoint_privileged_user_exposed_credentials",
"entra_admin_consent_workflow_enabled",
"entra_admin_portals_access_restriction",
"entra_admin_users_cloud_only",
"entra_admin_users_mfa_enabled",
"entra_admin_users_phishing_resistant_mfa_enabled",
"entra_admin_users_sign_in_frequency_enabled",
"entra_app_registration_no_unused_privileged_permissions",
"entra_policy_ensure_default_user_cannot_create_tenants",
"entra_policy_guest_invite_only_for_admin_roles"
"entra_policy_guest_invite_only_for_admin_roles",
"entra_seamless_sso_disabled"
]
},
{
@@ -673,6 +686,7 @@
"entra_admin_users_sign_in_frequency_enabled",
"entra_admin_users_mfa_enabled",
"entra_managed_device_required_for_authentication",
"entra_seamless_sso_disabled",
"entra_users_mfa_enabled",
"entra_identity_protection_sign_in_risk_enabled"
]
@@ -715,7 +729,9 @@
"Checks": [
"defender_malware_policy_common_attachments_filter_enabled",
"defender_malware_policy_comprehensive_attachments_filter_applied",
"defender_malware_policy_notifications_internal_users_malware_enabled"
"defender_malware_policy_notifications_internal_users_malware_enabled",
"defenderxdr_endpoint_privileged_user_exposed_credentials",
"defender_identity_health_issues_no_open"
]
},
{
@@ -765,8 +781,9 @@
}
],
"Checks": [
"entra_thirdparty_integrated_apps_not_allowed",
"entra_app_registration_no_unused_privileged_permissions",
"entra_policy_restricts_user_consent_for_apps",
"entra_thirdparty_integrated_apps_not_allowed",
"teams_external_domains_restricted",
"teams_external_users_cannot_start_conversations"
]
@@ -857,9 +874,10 @@
}
],
"Checks": [
"entra_policy_restricts_user_consent_for_apps",
"admincenter_users_admins_reduced_license_footprint",
"defender_malware_policy_comprehensive_attachments_filter_applied",
"entra_app_registration_no_unused_privileged_permissions",
"entra_policy_restricts_user_consent_for_apps",
"entra_thirdparty_integrated_apps_not_allowed",
"sharepoint_modern_authentication_required"
]
@@ -387,6 +387,7 @@
"Id": "1.2.4",
"Description": "Enable Identity Protection user risk policies",
"Checks": [
"defenderxdr_endpoint_privileged_user_exposed_credentials",
"entra_identity_protection_user_risk_enabled"
],
"Attributes": [
@@ -712,6 +713,7 @@
"Id": "1.3.3",
"Description": "Ensure third party integrated applications are not allowed",
"Checks": [
"entra_app_registration_no_unused_privileged_permissions",
"entra_thirdparty_integrated_apps_not_allowed"
],
"Attributes": [
@@ -748,6 +750,7 @@
"Id": "1.3.5",
"Description": "Ensure user consent to apps accessing company data on their behalf is not allowed",
"Checks": [
"entra_app_registration_no_unused_privileged_permissions",
"entra_policy_restricts_user_consent_for_apps"
],
"Attributes": [
@@ -1145,7 +1148,8 @@
"Id": "4.1.2",
"Description": "Ensure that password hash sync is enabled for hybrid deployments",
"Checks": [
"entra_password_hash_sync_enabled"
"entra_password_hash_sync_enabled",
"entra_seamless_sso_disabled"
],
"Attributes": [
{
Whitespace-only changes.
@@ -0,0 +1,6 @@
from prowler.providers.common.provider import Provider
from prowler.providers.m365.services.defenderidentity.defenderidentity_service import (
DefenderIdentity,
)
defenderidentity_client = DefenderIdentity(Provider.get_global_provider())
@@ -0,0 +1,37 @@
{
"Provider": "m365",
"CheckID": "defenderidentity_health_issues_no_open",
"CheckTitle": "Defender for Identity has no unresolved health issues affecting hybrid infrastructure monitoring",
"CheckType": [],
"ServiceName": "defenderidentity",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "Defender for Identity Health Issue",
"ResourceGroup": "security",
"Description": "Microsoft Defender for Identity (MDI) monitors your hybrid identity infrastructure and detects advanced threats targeting Active Directory. This check verifies that MDI sensors are deployed and that there are no unresolved health issues that may affect the ability to detect identity-based attacks.",
"Risk": "Without deployed MDI sensors or with unresolved health issues, organizations face critical gaps in threat detection. Misconfigured or missing sensors fail to monitor domain controllers, allowing identity-based attacks like Pass-the-Hash, Golden Ticket, or lateral movement to go undetected. Attackers commonly exploit these blind spots to compromise hybrid environments while evading detection.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/defender-for-identity/health-alerts",
"https://learn.microsoft.com/en-us/graph/api/security-identitycontainer-list-healthissues"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to Microsoft Defender XDR portal at https://security.microsoft.com/\n2. Go to Settings > Identities > Health issues\n3. Review each open health issue and its recommendations\n4. Follow the specific remediation steps provided for each issue\n5. Verify the issue is resolved and status changes to closed",
"Terraform": ""
},
"Recommendation": {
"Text": "Regularly monitor and resolve Defender for Identity health issues to maintain comprehensive visibility into identity-based threats across your hybrid infrastructure.",
"Url": "https://hub.prowler.com/check/defenderidentity_health_issues_no_open"
}
},
"Categories": [
"e5"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "This check requires SecurityIdentitiesHealth.Read.All permission and a hybrid identity environment with Active Directory on-premises connected to Microsoft Defender for Identity. Health issues can be global (domain-related, such as Directory Services account issues or auditing misconfigurations) or sensor-specific. If no hybrid AD environment is configured, this check will pass with no health issues detected, as MDI only monitors on-premises Active Directory infrastructure."
}
@@ -0,0 +1,140 @@
"""Check for open health issues in Microsoft Defender for Identity.
This module provides a security check that verifies there are no unresolved
health issues in the Microsoft Defender for Identity deployment.
"""
from typing import List
from prowler.lib.check.models import Check, CheckReportM365, Severity
from prowler.providers.m365.services.defenderidentity.defenderidentity_client import (
defenderidentity_client,
)
class defenderidentity_health_issues_no_open(Check):
"""Ensure Microsoft Defender for Identity has no unresolved health issues.
This check evaluates whether there are open health issues in the MDI deployment
that require attention to maintain proper hybrid identity protection.
- PASS: The health issue has been resolved (status is not open).
- FAIL: The health issue is open and requires attention.
- FAIL: No sensors are deployed (MDI cannot protect the environment).
"""
def execute(self) -> List[CheckReportM365]:
"""Execute the check for open MDI health issues.
This method iterates through all health issues from Microsoft Defender
for Identity and reports on their status. Open issues indicate potential
configuration problems or sensor health concerns that need resolution.
Returns:
List[CheckReportM365]: A list of reports containing the result of the check.
"""
findings = []
# Check sensors first - None means API error, empty list means no sensors
sensors_api_failed = defenderidentity_client.sensors is None
health_issues_api_failed = defenderidentity_client.health_issues is None
has_sensors = (
defenderidentity_client.sensors and len(defenderidentity_client.sensors) > 0
)
# If both APIs failed, it's likely a permission issue
if sensors_api_failed and health_issues_api_failed:
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Defender for Identity",
resource_id="defenderIdentity",
)
report.status = "FAIL"
report.status_extended = (
"Defender for Identity APIs are not accessible. "
"Ensure the Service Principal has SecurityIdentitiesSensors.Read.All and "
"SecurityIdentitiesHealth.Read.All permissions granted."
)
findings.append(report)
return findings
# If only health issues API failed but we have sensors
if health_issues_api_failed and has_sensors:
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Defender for Identity",
resource_id="defenderIdentity",
)
report.status = "FAIL"
report.status_extended = (
f"Cannot read health issues from Defender for Identity "
f"(found {len(defenderidentity_client.sensors)} sensor(s) deployed). "
"Ensure the Service Principal has SecurityIdentitiesHealth.Read.All permission."
)
findings.append(report)
return findings
# If no sensors are deployed (empty list, not None), MDI cannot monitor
if not has_sensors and not sensors_api_failed:
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Defender for Identity",
resource_id="defenderIdentity",
)
report.status = "FAIL"
report.status_extended = (
"No sensors deployed in Defender for Identity. "
"Without sensors, MDI cannot monitor health issues in the environment. "
"Deploy sensors on domain controllers to enable protection."
)
findings.append(report)
return findings
# If health_issues is empty list - no issues exist, this is compliant
if not defenderidentity_client.health_issues:
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Defender for Identity",
resource_id="defenderIdentity",
)
report.status = "PASS"
report.status_extended = (
"No open health issues found in Defender for Identity."
)
findings.append(report)
return findings
for health_issue in defenderidentity_client.health_issues:
report = CheckReportM365(
metadata=self.metadata(),
resource=health_issue,
resource_name=health_issue.display_name,
resource_id=health_issue.id,
)
issue_type = health_issue.health_issue_type or "unknown"
severity = health_issue.severity or "unknown"
status = (health_issue.status or "").lower()
if status != "open":
report.status = "PASS"
report.status_extended = f"Defender for Identity {issue_type} health issue {health_issue.display_name} is resolved."
else:
report.status = "FAIL"
report.status_extended = f"Defender for Identity {issue_type} health issue {health_issue.display_name} is open with {severity} severity."
# Adjust severity based on issue severity
if severity == "high":
report.check_metadata.Severity = Severity.high
elif severity == "medium":
report.check_metadata.Severity = Severity.medium
elif severity == "low":
report.check_metadata.Severity = Severity.low
findings.append(report)
return findings
@@ -0,0 +1,292 @@
"""Microsoft Defender for Identity service module.
This module provides the DefenderIdentity service class for interacting with
Microsoft Defender for Identity (MDI) APIs, including health issues and sensors.
"""
import asyncio
from typing import List, Optional
from pydantic.v1 import BaseModel
from prowler.lib.logger import logger
from prowler.providers.m365.lib.service.service import M365Service
from prowler.providers.m365.m365_provider import M365Provider
class DefenderIdentity(M365Service):
"""Microsoft Defender for Identity service class.
This class provides methods to retrieve and manage Microsoft Defender for Identity
health issues, which monitor the health status of MDI configuration and sensors.
Attributes:
health_issues (list[HealthIssue]): List of health issues from MDI.
sensors (list[Sensor]): List of sensors from MDI.
"""
def __init__(self, provider: M365Provider):
"""Initialize the DefenderIdentity service client.
Args:
provider: The M365Provider instance for authentication and configuration.
"""
super().__init__(provider)
self.sensors: Optional[List[Sensor]] = []
self.health_issues: Optional[List[HealthIssue]] = []
created_loop = False
try:
loop = asyncio.get_running_loop()
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
created_loop = True
if loop.is_closed():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
created_loop = True
if loop.is_running():
raise RuntimeError(
"Cannot initialize DefenderIdentity service while event loop is running"
)
self.sensors = loop.run_until_complete(self._get_sensors())
self.health_issues = loop.run_until_complete(self._get_health_issues())
if created_loop:
asyncio.set_event_loop(None)
loop.close()
async def _get_sensors(self) -> Optional[List["Sensor"]]:
"""Retrieve sensors from Microsoft Defender for Identity.
This method fetches all MDI sensors deployed in the environment,
including their health status and configuration.
Returns:
Optional[List[Sensor]]: A list of sensors from MDI,
or None if the API call failed (tenant not onboarded or missing permissions).
"""
logger.info("DefenderIdentity - Getting sensors...")
sensors: Optional[List[Sensor]] = []
# Step 1: Call the API
try:
sensors_response = await self.client.security.identities.sensors.get()
except Exception as error:
error_msg = str(error)
if "403" in error_msg or "Forbidden" in error_msg:
logger.error(
"DefenderIdentity - Permission denied accessing sensors API. "
"Ensure the Service Principal has SecurityIdentitiesSensors.Read.All permission."
)
elif "401" in error_msg or "Unauthorized" in error_msg:
logger.error(
"DefenderIdentity - Authentication failed accessing sensors API. "
"Verify the Service Principal credentials are valid."
)
else:
logger.error(
f"DefenderIdentity - API error getting sensors: "
f"{error.__class__.__name__}: {error}"
)
return None
# Step 2: Parse the response
try:
while sensors_response:
for sensor in getattr(sensors_response, "value", []) or []:
sensors.append(
Sensor(
id=getattr(sensor, "id", ""),
display_name=getattr(sensor, "display_name", ""),
sensor_type=(
str(getattr(sensor, "sensor_type", ""))
if getattr(sensor, "sensor_type", None)
else None
),
deployment_status=(
str(getattr(sensor, "deployment_status", ""))
if getattr(sensor, "deployment_status", None)
else None
),
health_status=(
str(getattr(sensor, "health_status", ""))
if getattr(sensor, "health_status", None)
else None
),
open_health_issues_count=getattr(
sensor, "open_health_issues_count", 0
)
or 0,
domain_name=getattr(sensor, "domain_name", ""),
version=getattr(sensor, "version", ""),
created_date_time=str(
getattr(sensor, "created_date_time", "")
),
)
)
next_link = getattr(sensors_response, "odata_next_link", None)
if not next_link:
break
sensors_response = (
await self.client.security.identities.sensors.with_url(
next_link
).get()
)
except Exception as error:
logger.error(
f"DefenderIdentity - Error parsing sensors response: "
f"{error.__class__.__name__}: {error}"
)
return None
return sensors
async def _get_health_issues(self) -> Optional[List["HealthIssue"]]:
"""Retrieve health issues from Microsoft Defender for Identity.
This method fetches all health issues from the MDI deployment including
both global and sensor-specific health alerts.
Returns:
Optional[List[HealthIssue]]: A list of health issues from MDI,
or None if the API call failed (tenant not onboarded or missing permissions).
"""
logger.info("DefenderIdentity - Getting health issues...")
health_issues: Optional[List[HealthIssue]] = []
# Step 1: Call the API
try:
health_issues_response = (
await self.client.security.identities.health_issues.get()
)
except Exception as error:
error_msg = str(error)
if "403" in error_msg or "Forbidden" in error_msg:
logger.error(
"DefenderIdentity - Permission denied accessing health issues API. "
"Ensure the Service Principal has SecurityIdentitiesHealth.Read.All permission."
)
elif "401" in error_msg or "Unauthorized" in error_msg:
logger.error(
"DefenderIdentity - Authentication failed accessing health issues API. "
"Verify the Service Principal credentials are valid."
)
else:
logger.error(
f"DefenderIdentity - API error getting health issues: "
f"{error.__class__.__name__}: {error}"
)
return None
# Step 2: Parse the response
try:
while health_issues_response:
for issue in getattr(health_issues_response, "value", []) or []:
health_issues.append(
HealthIssue(
id=getattr(issue, "id", ""),
display_name=getattr(issue, "display_name", ""),
description=getattr(issue, "description", ""),
health_issue_type=getattr(issue, "health_issue_type", None),
severity=getattr(issue, "severity", None),
status=getattr(issue, "status", None),
created_date_time=str(
getattr(issue, "created_date_time", "")
),
last_modified_date_time=str(
getattr(issue, "last_modified_date_time", "")
),
domain_names=getattr(issue, "domain_names", []) or [],
sensor_dns_names=getattr(issue, "sensor_d_n_s_names", [])
or [],
issue_type_id=getattr(issue, "issue_type_id", None),
recommendations=getattr(issue, "recommendations", []) or [],
additional_information=getattr(
issue, "additional_information", []
)
or [],
)
)
next_link = getattr(health_issues_response, "odata_next_link", None)
if not next_link:
break
health_issues_response = (
await self.client.security.identities.health_issues.with_url(
next_link
).get()
)
except Exception as error:
logger.error(
f"DefenderIdentity - Error parsing health issues response: "
f"{error.__class__.__name__}: {error}"
)
return None
return health_issues
class Sensor(BaseModel):
"""Model for Microsoft Defender for Identity sensor.
Attributes:
id: The unique identifier for the sensor.
display_name: The display name of the sensor.
sensor_type: The type of sensor (domainControllerIntegrated, domainControllerStandalone, adfsIntegrated).
deployment_status: The deployment status (upToDate, outdated, updating, updateFailed, notConfigured).
health_status: The health status of the sensor (healthy, notHealthyLow, notHealthyMedium, notHealthyHigh).
open_health_issues_count: Number of open health issues for this sensor.
domain_name: The domain name the sensor is monitoring.
version: The version of the sensor.
created_date_time: When the sensor was created.
"""
id: str
display_name: str
sensor_type: Optional[str]
deployment_status: Optional[str]
health_status: Optional[str]
open_health_issues_count: int
domain_name: str
version: str
created_date_time: str
class HealthIssue(BaseModel):
"""Model for Microsoft Defender for Identity health issue.
Attributes:
id: The unique identifier for the health issue.
display_name: The display name of the health issue.
description: A detailed description of the health issue.
health_issue_type: The type of health issue (global or sensor).
severity: The severity level of the issue (low, medium, high).
status: The current status of the issue (open, closed).
created_date_time: When the issue was created.
last_modified_date_time: When the issue was last modified.
domain_names: List of domain names affected by the issue.
sensor_dns_names: List of sensor DNS names affected by the issue.
issue_type_id: The type identifier for the issue.
recommendations: List of recommended actions to resolve the issue.
additional_information: Additional information about the issue.
"""
id: str
display_name: str
description: str
health_issue_type: Optional[str]
severity: Optional[str]
status: Optional[str]
created_date_time: str
last_modified_date_time: str
domain_names: List[str]
sensor_dns_names: List[str]
issue_type_id: Optional[str]
recommendations: List[str]
additional_information: List[str]
Whitespace-only changes.
@@ -0,0 +1,4 @@
from prowler.providers.common.provider import Provider
from prowler.providers.m365.services.defenderxdr.defenderxdr_service import DefenderXDR
defenderxdr_client = DefenderXDR(Provider.get_global_provider())
@@ -0,0 +1,37 @@
{
"Provider": "m365",
"CheckID": "defenderxdr_critical_asset_management_pending_approvals",
"CheckTitle": "Ensure all Critical Asset Management classifications are reviewed and approved in Microsoft Defender XDR",
"CheckType": [],
"ServiceName": "defenderxdr",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "Defender XDR Critical Asset Management",
"ResourceGroup": "security",
"Description": "Assets with a lower classification confidence score in Microsoft Defender XDR must be approved by a security administrator.\n\nAsset classifications that have not yet been reviewed and approved may result in incomplete **critical asset** visibility.",
"Risk": "Stale pending approvals lead to limited visibility in Microsoft Defender XDR. **Critical assets** that are not properly identified and classified may not receive appropriate security monitoring and protections, creating gaps in the organization's security posture.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/security-exposure-management/classify-critical-assets",
"https://learn.microsoft.com/en-us/security-exposure-management/classify-critical-assets#review-critical-assets"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to **Microsoft Defender** at https://security.microsoft.com/\n2. Go to **Settings** > **Microsoft Defender XDR** > **Critical asset management**\n3. Review each pending approval listed in the check results\n4. Verify the correct classification for each asset\n5. Approve or reject the classification as appropriate",
"Terraform": ""
},
"Recommendation": {
"Text": "Regularly review and approve pending critical asset classifications to ensure accurate asset visibility in Microsoft Defender XDR. Stale approvals reduce the effectiveness of security monitoring and incident response for critical assets.",
"Url": "https://hub.prowler.com/check/defenderxdr_critical_asset_management_pending_approvals"
}
},
"Categories": [
"e5"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "This check requires Microsoft Defender XDR with Security Exposure Management enabled. The ThreatHunting.Read.All permission is required to query the ExposureGraphNodes table via the Advanced Hunting API. Approved assets will be reflected in the classification table within 24 hours."
}
@@ -0,0 +1,86 @@
"""Check for pending Critical Asset Management approvals in Defender XDR.
This check identifies asset classifications with low confidence scores
that require security administrator review and approval.
"""
from typing import List
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.defenderxdr.defenderxdr_client import (
defenderxdr_client,
)
class defenderxdr_critical_asset_management_pending_approvals(Check):
"""Check for pending Critical Asset Management approvals in Microsoft Defender XDR.
This check queries Advanced Hunting to identify assets with low classification
confidence scores that have not been reviewed by a security administrator.
Prerequisites:
1. ThreatHunting.Read.All permission granted
2. Microsoft Defender XDR with Security Exposure Management enabled
Results:
- PASS: No pending approvals for Critical Asset Management are found.
- FAIL: At least one asset classification has pending approvals.
"""
def execute(self) -> List[CheckReportM365]:
"""Execute the check for pending Critical Asset Management approvals.
Evaluates whether there are any pending Critical Asset Management
approvals that require administrator review.
Returns:
A list of reports containing the result of the check.
"""
findings = []
pending_approvals = defenderxdr_client.pending_cam_approvals
# API call failed - likely missing ThreatHunting.Read.All permission
if pending_approvals is None:
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Critical Asset Management",
resource_id="criticalAssetManagement",
)
report.status = "FAIL"
report.status_extended = (
"Unable to query Critical Asset Management status. "
"Verify that ThreatHunting.Read.All permission is granted."
)
findings.append(report)
return findings
if not pending_approvals:
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Critical Asset Management",
resource_id="criticalAssetManagement",
)
report.status = "PASS"
report.status_extended = "No pending approvals for Critical Asset Management classifications are found."
findings.append(report)
else:
for approval in pending_approvals:
report = CheckReportM365(
metadata=self.metadata(),
resource=approval,
resource_name=f"CAM Classification: {approval.classification}",
resource_id=f"cam/{approval.classification}",
)
report.status = "FAIL"
assets_summary = ", ".join(approval.assets[:5])
if len(approval.assets) > 5:
assets_summary += f" and {len(approval.assets) - 5} more"
report.status_extended = (
f"Critical Asset Management classification '{approval.classification}' "
f"has {approval.pending_count} asset(s) pending approval: {assets_summary}."
)
findings.append(report)
return findings
@@ -0,0 +1,39 @@
{
"Provider": "m365",
"CheckID": "defenderxdr_endpoint_privileged_user_exposed_credentials",
"CheckTitle": "Privileged users do not have credentials exposed on vulnerable endpoints",
"CheckType": [],
"ServiceName": "defenderxdr",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "critical",
"ResourceType": "Exposure Management",
"ResourceGroup": "security",
"Description": "Privileged users may have authentication artifacts (CLI secrets, cookies, tokens) exposed on endpoints with high risk scores. Microsoft Defender XDR's Security Exposure Management detects when credentials from users with Entra ID privileged roles are present on vulnerable devices.",
"Risk": "Exposed credentials on vulnerable endpoints enable account takeover through stolen tokens or cookies, Conditional Access bypass via primary refresh tokens, lateral movement to sensitive resources, and persistence until tokens are explicitly revoked.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/security-exposure-management/prerequisites",
"https://learn.microsoft.com/en-us/defender-xdr/advanced-hunting-exposuregraphedges-table"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to Microsoft Defender portal at https://security.microsoft.com\n2. Go to Exposure Management > Attack surface > Attack paths\n3. Review the exposed credential findings for privileged users\n4. For each affected device, review the risk and exposure score in Device Inventory\n5. Remediate endpoint vulnerabilities and improve device security posture\n6. Revoke affected user sessions and rotate credentials\n7. Consider implementing Privileged Access Workstations (PAWs) for privileged users",
"Terraform": ""
},
"Recommendation": {
"Text": "Privileged users should only authenticate from secure, hardened devices with low exposure scores. Implement Privileged Access Workstations (PAWs) and enforce device compliance policies through Conditional Access to prevent credential exposure on vulnerable endpoints.",
"Url": "https://hub.prowler.com/check/defenderxdr_endpoint_privileged_user_exposed_credentials"
}
},
"Categories": [
"secrets",
"identity-access",
"e5"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "This check requires Microsoft Defender XDR with Security Exposure Management enabled. The ThreatHunting.Read.All permission is required to query the ExposureGraphEdges table via the Advanced Hunting API."
}
@@ -0,0 +1,148 @@
"""Check for exposed credentials of privileged users in Defender XDR.
This check identifies privileged users whose authentication credentials
(CLI secrets, cookies, tokens) are exposed on vulnerable endpoints.
"""
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.defenderxdr.defenderxdr_client import (
defenderxdr_client,
)
class defenderxdr_endpoint_privileged_user_exposed_credentials(Check):
"""Check if privileged users have exposed credentials on endpoints.
This check queries Microsoft Defender XDR's ExposureGraphEdges
table via the Advanced Hunting API to identify privileged users whose
authentication artifacts (CLI secrets, user cookies, sensitive tokens)
are exposed on endpoints with high risk or exposure scores.
Prerequisites:
1. ThreatHunting.Read.All permission granted
2. Microsoft Defender for Endpoint (MDE) enabled and deployed on devices
Results:
- PASS: No exposed credentials found OR MDE enabled but no devices
- FAIL: Exposed credentials detected OR MDE not enabled (blind spot)
"""
def execute(self) -> list[CheckReportM365]:
"""Execute the check for exposed credentials of privileged users.
Returns:
List[CheckReportM365]: A list of reports with check results.
"""
findings = []
# Step 1: Check MDE status
mde_status = defenderxdr_client.mde_status
# API call failed - likely missing ThreatHunting.Read.All permission
if mde_status is None:
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Defender XDR",
resource_id="mdeStatus",
)
report.status = "FAIL"
report.status_extended = (
"Unable to query Microsoft Defender XDR status. "
"Verify that ThreatHunting.Read.All permission is granted."
)
findings.append(report)
return findings
# MDE not enabled - this is a security blind spot
if mde_status == "not_enabled":
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Defender XDR",
resource_id="mdeStatus",
)
report.status = "FAIL"
report.status_extended = (
"Microsoft Defender for Endpoint is not enabled. "
"Without MDE there is no visibility into credential "
"exposure on endpoints."
)
findings.append(report)
return findings
# MDE enabled but no devices - PASS (no endpoints to evaluate)
if mde_status == "no_devices":
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Defender XDR",
resource_id="mdeDevices",
)
report.status = "PASS"
report.status_extended = (
"Microsoft Defender for Endpoint is enabled but no devices "
"are onboarded. No endpoints to evaluate for credential "
"exposure."
)
findings.append(report)
return findings
# Step 2: MDE is active with devices - check for exposed credentials
exposed_credentials = defenderxdr_client.exposed_credentials_privileged_users
# API call failed for exposed credentials query
if exposed_credentials is None:
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Defender XDR",
resource_id="exposedCredentials",
)
report.status = "FAIL"
report.status_extended = (
"Unable to query Security Exposure Management for exposed "
"credentials. Verify that Security Exposure Management "
"is enabled."
)
findings.append(report)
return findings
# Found exposed credentials - report each one
if exposed_credentials:
for exposed_user in exposed_credentials:
report = CheckReportM365(
metadata=self.metadata(),
resource=exposed_user,
resource_name=exposed_user.target_node_name,
resource_id=(exposed_user.target_node_id or exposed_user.edge_id),
)
report.status = "FAIL"
credential_info = (
f" ({exposed_user.credential_type})"
if exposed_user.credential_type
else ""
)
report.status_extended = (
f"Privileged user {exposed_user.target_node_name} has "
f"exposed credentials{credential_info} on device "
f"{exposed_user.source_node_name}."
)
findings.append(report)
else:
# No exposed credentials found - full visibility, no risk detected
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="Defender XDR Exposure Management",
resource_id="exposedCredentials",
)
report.status = "PASS"
report.status_extended = (
"No exposed credentials found for privileged users on "
"vulnerable endpoints."
)
findings.append(report)
return findings
@@ -0,0 +1,322 @@
"""Microsoft Defender XDR service module.
This module provides access to Microsoft Defender XDR data
through the Microsoft Graph Security Advanced Hunting API.
"""
import asyncio
import json
from typing import Dict, List, Optional
from msgraph.generated.security.microsoft_graph_security_run_hunting_query.run_hunting_query_post_request_body import (
RunHuntingQueryPostRequestBody,
)
from pydantic.v1 import BaseModel
from prowler.lib.logger import logger
from prowler.providers.m365.lib.service.service import M365Service
from prowler.providers.m365.m365_provider import M365Provider
class DefenderXDR(M365Service):
"""Microsoft Defender XDR service class.
Provides access to Microsoft Defender XDR data through
the Microsoft Graph Security Advanced Hunting API.
This class handles endpoint security checks including:
- Device security posture
- Exposed credentials detection
- Vulnerability assessments
- Critical Asset Management approvals
Attributes:
mde_status: Status of MDE deployment
(None, "not_enabled", "no_devices", "active")
exposed_credentials_privileged_users: List of privileged users
with exposed credentials
pending_cam_approvals: List of pending Critical Asset Management
approvals (None if API error)
"""
def __init__(self, provider: M365Provider):
"""Initialize the DefenderXDR service client.
Args:
provider: The M365Provider instance for authentication.
"""
super().__init__(provider)
# MDE status: None = API error, "not_enabled" = table not found,
# "no_devices" = enabled but empty, "active" = has devices
self.mde_status: Optional[str] = None
# Check data
self.exposed_credentials_privileged_users: Optional[
List[ExposedCredentialPrivilegedUser]
] = []
self.pending_cam_approvals: Optional[List[PendingCAMApproval]] = []
loop = self._get_event_loop()
try:
(
self.mde_status,
self.exposed_credentials_privileged_users,
self.pending_cam_approvals,
) = loop.run_until_complete(
asyncio.gather(
self._check_mde_status(),
self._get_exposed_credentials_privileged_users(),
self._get_pending_cam_approvals(),
)
)
finally:
self._cleanup_event_loop(loop)
def _get_event_loop(self) -> asyncio.AbstractEventLoop:
"""Get or create an event loop for async operations."""
try:
loop = asyncio.get_running_loop()
if loop.is_running():
raise RuntimeError(
"Cannot initialize DefenderXDR service while event loop is running"
)
return loop
except RuntimeError:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
return loop
def _cleanup_event_loop(self, loop: asyncio.AbstractEventLoop) -> None:
"""Clean up the event loop if we created it."""
try:
if loop and not loop.is_running():
asyncio.set_event_loop(None)
loop.close()
except Exception as error:
# Best-effort cleanup: swallow errors but log them for diagnostics
logger.debug(f"DefenderXDR - Failed to clean up event loop: {error}")
async def _run_hunting_query(self, query: str) -> tuple[Optional[List[Dict]], bool]:
"""Execute an Advanced Hunting query using Microsoft Graph Security API.
Args:
query: The KQL (Kusto Query Language) query to execute.
Returns:
Tuple of (results, table_not_found):
- results: List of result dicts, empty list if no results,
None if API error.
- table_not_found: True if query failed because table
doesn't exist.
"""
try:
request_body = RunHuntingQueryPostRequestBody(query=query)
response = await self.client.security.microsoft_graph_security_run_hunting_query.post(
request_body
)
if not response or not response.results:
return [], False
results = [
row.additional_data
for row in response.results
if hasattr(row, "additional_data")
]
return results, False
except Exception as error:
error_message = str(error).lower()
if (
"failed to resolve table" in error_message
or "could not find table" in error_message
):
logger.warning(f"DefenderXDR - Table not found in query: {error}")
return [], True
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return None, False
async def _check_mde_status(self) -> Optional[str]:
"""Check Microsoft Defender for Endpoint status.
Returns:
- None: API call failed (permission issue)
- "not_enabled": DeviceInfo table doesn't exist (MDE not enabled)
- "no_devices": MDE enabled but no devices onboarded
- "active": MDE enabled with devices reporting
"""
logger.info("DefenderXDR - Checking MDE status...")
query = "DeviceInfo | summarize DeviceCount = count()"
results, table_not_found = await self._run_hunting_query(query)
if results is None:
return None
if table_not_found:
return "not_enabled"
if results and len(results) > 0:
device_count = results[0].get("DeviceCount", 0)
if device_count > 0:
return "active"
return "no_devices"
async def _get_exposed_credentials_privileged_users(
self,
) -> Optional[List["ExposedCredentialPrivilegedUser"]]:
"""Query for privileged users with exposed credentials.
Returns:
List of ExposedCredentialPrivilegedUser objects,
or None if API call failed.
"""
logger.info(
"DefenderXDR - Querying for exposed credentials of privileged users..."
)
query = """
ExposureGraphEdges
| where EdgeLabel == "hasCredentialsFor"
| where TargetNodeLabel == "user"
| extend targetCategories = parse_json(TargetNodeCategories)
| where targetCategories has "PrivilegedEntraIdRole" or targetCategories has "privileged"
| extend credentialType = tostring(parse_json(EdgeProperties).credentialType)
| project
EdgeId,
SourceNodeId,
SourceNodeName,
SourceNodeLabel,
TargetNodeId,
TargetNodeName,
TargetNodeLabel,
CredentialType = credentialType,
TargetCategories = TargetNodeCategories
"""
results, _ = await self._run_hunting_query(query)
if results is None:
return None
return [self._parse_exposed_credential(row) for row in results if row]
def _parse_exposed_credential(self, row: Dict) -> "ExposedCredentialPrivilegedUser":
"""Parse a single row into an ExposedCredentialPrivilegedUser."""
target_categories = row.get("TargetCategories", [])
if isinstance(target_categories, str):
try:
target_categories = json.loads(target_categories)
except (json.JSONDecodeError, ValueError):
target_categories = []
return ExposedCredentialPrivilegedUser(
edge_id=str(row.get("EdgeId", "")),
source_node_id=str(row.get("SourceNodeId", "")),
source_node_name=str(row.get("SourceNodeName", "Unknown")),
source_node_label=str(row.get("SourceNodeLabel", "")),
target_node_id=str(row.get("TargetNodeId", "")),
target_node_name=str(row.get("TargetNodeName", "Unknown")),
target_node_label=str(row.get("TargetNodeLabel", "")),
credential_type=str(row.get("CredentialType") or "Unknown"),
target_categories=target_categories,
)
async def _get_pending_cam_approvals(
self,
) -> Optional[List["PendingCAMApproval"]]:
"""Query for pending Critical Asset Management approvals.
Queries the ExposureGraphNodes table to find assets with low criticality
confidence scores that require administrator approval.
Returns:
List of PendingCAMApproval objects, or None if API call failed.
"""
logger.info(
"DefenderXDR - Querying for pending Critical Asset Management approvals..."
)
query = """
ExposureGraphNodes
| where isnotempty(parse_json(NodeProperties)['rawData']['criticalityConfidenceLow'])
| mv-expand parse_json(NodeProperties)['rawData']['criticalityConfidenceLow']
| extend Classification = tostring(NodeProperties_rawData_criticalityConfidenceLow)
| summarize PendingApproval = count(), Assets = array_sort_asc(make_set(NodeName)) by Classification
| sort by Classification asc
"""
results, _ = await self._run_hunting_query(query)
if results is None:
return None
pending_approvals = []
for row in results:
if not row:
continue
classification = row.get("Classification", "")
pending_count = int(row.get("PendingApproval", 0))
assets_raw = row.get("Assets", "[]")
if isinstance(assets_raw, str):
try:
assets = json.loads(assets_raw)
except (json.JSONDecodeError, ValueError):
assets = []
elif isinstance(assets_raw, list):
assets = assets_raw
else:
assets = []
pending_approvals.append(
PendingCAMApproval(
classification=classification,
pending_count=pending_count,
assets=assets,
)
)
return pending_approvals
class ExposedCredentialPrivilegedUser(BaseModel):
"""Model for exposed credential data of a privileged user.
Represents authentication credentials (CLI secrets, user cookies, tokens)
of privileged users that are exposed on vulnerable endpoints.
"""
edge_id: str
source_node_id: str
source_node_name: str
source_node_label: str
target_node_id: str
target_node_name: str
target_node_label: str
credential_type: Optional[str] = None
target_categories: list = []
class PendingCAMApproval(BaseModel):
"""Model for a pending Critical Asset Management approval classification.
Represents assets with low criticality confidence scores that require
security administrator review and approval.
Attributes:
classification: The asset classification name pending approval.
pending_count: The number of assets pending approval for this classification.
assets: List of asset names pending approval.
"""
classification: str
pending_count: int
assets: List[str]
@@ -0,0 +1,37 @@
{
"Provider": "m365",
"CheckID": "entra_app_registration_no_unused_privileged_permissions",
"CheckTitle": "App registration has no unused privileged API permissions",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "App Registration",
"ResourceGroup": "IAM",
"Description": "OAuth app registrations with privileged API permissions (High privilege level) that are not being actively used. Usage status is determined by Microsoft Defender for Cloud Apps App Governance.",
"Risk": "Unused privileged permissions expand the attack surface. If a compromised app has dormant privileged permissions, attackers can exploit them for **privilege escalation**, **unauthorized access** to sensitive data, or **lateral movement** within the environment.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/defender-cloud-apps/app-governance-visibility-insights-overview",
"https://learn.microsoft.com/en-us/defender-xdr/advanced-hunting-oauthappinfo-table"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Navigate to Microsoft Defender XDR portal (https://security.microsoft.com)\n2. Go to Cloud apps > App governance > Overview\n3. Review the Applications inventory for apps with unused permissions\n4. For each flagged app, view details and navigate to the Permissions tab\n5. Remove unnecessary permissions via Microsoft Entra admin center",
"Terraform": ""
},
"Recommendation": {
"Text": "Apply the **principle of least privilege** by regularly reviewing and revoking unused privileged permissions from app registrations. Use Microsoft Defender for Cloud Apps App Governance to monitor permission usage.",
"Url": "https://hub.prowler.com/check/entra_app_registration_no_unused_privileged_permissions"
}
},
"Categories": [
"identity-access"
],
"DependsOn": [],
"RelatedTo": [],
"Notes": "This check requires Microsoft Defender for Cloud Apps with App Governance enabled and ThreatHunting.Read.All permission. If App Governance data is unavailable, the check fails due to missing visibility."
}
@@ -0,0 +1,145 @@
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
class entra_app_registration_no_unused_privileged_permissions(Check):
"""
Ensure that app registrations do not have unused privileged API permissions.
This check evaluates OAuth applications registered in Microsoft Entra ID to identify
those with privileged API permissions (High privilege level or Control/Management Plane
classifications) that are assigned but not actively being used.
The check uses data from Microsoft Defender for Cloud Apps App Governance via
the OAuthAppInfo table in Defender XDR Advanced Hunting.
- PASS: The app has no unused privileged permissions.
- FAIL: The app has one or more unused privileged permissions that should be revoked.
It also fails when OAuth App Governance data is not available.
"""
# InUse field values from OAuthAppInfo:
# - "true" / "1" / "True" = permission is actively used
# - "false" / "0" / "False" = permission is NOT used (this triggers FAIL)
# - "Not supported" = Microsoft cannot determine usage
# - "" (empty) = No tracking data available
# Note: Microsoft is changing from numeric (1/0) to textual (True/False) on Feb 25, 2026
_UNUSED_STATUSES = {"false", "0", "notinuse", "not in use"}
_PRIVILEGED_PLANE_LABELS = ("control plane", "management plane")
def execute(self) -> list[CheckReportM365]:
"""
Execute the unused privileged permissions check for app registrations.
Iterates over OAuth applications retrieved from the Entra client and generates
reports indicating whether each app has unused privileged permissions.
Returns:
list[CheckReportM365]: A list of reports with the result of the check for each app.
"""
findings = []
# If OAuth app data is None, the API call failed (missing permissions or App Governance not enabled)
if entra_client.oauth_apps is None:
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="OAuth Applications",
resource_id="oauthApps",
)
report.status = "FAIL"
report.status_extended = (
"OAuth App Governance data is unavailable. "
"Enable App Governance in Microsoft Defender for Cloud Apps and "
"grant ThreatHunting.Read.All to evaluate unused privileged permissions."
)
findings.append(report)
return findings
# If OAuth apps is empty dict, no apps are registered - this is compliant
if not entra_client.oauth_apps:
report = CheckReportM365(
metadata=self.metadata(),
resource={},
resource_name="OAuth Applications",
resource_id="oauthApps",
)
report.status = "PASS"
report.status_extended = (
"No OAuth applications are registered in the tenant."
)
findings.append(report)
return findings
# Check each OAuth app for unused privileged permissions
for app_id, app in entra_client.oauth_apps.items():
report = CheckReportM365(
metadata=self.metadata(),
resource=app,
resource_name=app.name,
resource_id=app_id,
)
# Find unused privileged permissions
# A permission is considered privileged if it has:
# - PrivilegeLevel == "High"
# Or if it's part of Control Plane / Management Plane (typically High privilege)
unused_privileged_permissions = []
for permission in app.permissions:
# Check if the permission is privileged
is_privileged = self._is_privileged_permission(permission)
# Check if the permission is unused
normalized_usage = self._normalize(permission.usage_status)
is_unused = normalized_usage in self._UNUSED_STATUSES
if is_privileged and is_unused:
unused_privileged_permissions.append(permission.name)
if unused_privileged_permissions:
# The app has unused privileged permissions
report.status = "FAIL"
# Truncate list to first 5 permissions for readability
total_count = len(unused_privileged_permissions)
if total_count > 5:
displayed = unused_privileged_permissions[:5]
permissions_list = ", ".join(displayed)
remaining = total_count - 5
permissions_list += f" (and {remaining} more)"
else:
permissions_list = ", ".join(unused_privileged_permissions)
report.status_extended = (
f"App registration {app.name} has {total_count} "
f"unused privileged permission(s): {permissions_list}."
)
else:
# The app has no unused privileged permissions
report.status = "PASS"
report.status_extended = (
f"App registration {app.name} has no unused privileged permissions."
)
findings.append(report)
return findings
@classmethod
def _is_privileged_permission(cls, permission) -> bool:
privilege_level = cls._normalize(permission.privilege_level)
permission_type = cls._normalize(permission.permission_type)
classification = cls._normalize(getattr(permission, "classification", ""))
if privilege_level == "high":
return True
return any(
label in permission_type or label in classification
for label in cls._PRIVILEGED_PLANE_LABELS
)
@staticmethod
def _normalize(value: str) -> str:
return (
value.lower().replace("_", " ").replace("-", " ").strip() if value else ""
)
@@ -0,0 +1,38 @@
{
"Provider": "m365",
"CheckID": "entra_seamless_sso_disabled",
"CheckTitle": "Entra hybrid deployment does not have Seamless SSO enabled",
"CheckType": [],
"ServiceName": "entra",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "Directory Sync Settings",
"ResourceGroup": "IAM",
"Description": "**Seamless Single Sign-On (SSO)** in hybrid Microsoft Entra deployments allows automatic authentication for domain-joined devices on the corporate network.\n\nThis check verifies the actual Seamless SSO configuration in directory synchronization settings. Modern devices with **Primary Refresh Token** (PRT) support no longer require Seamless SSO.",
"Risk": "Seamless SSO can be exploited for **lateral movement** between on-premises domains and Entra ID when an Entra Connect server is compromised. It can also be used to perform **brute force attacks** against Entra ID, as authentication through the AZUREADSSOACC account bypasses standard protections.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://learn.microsoft.com/en-us/entra/identity/hybrid/connect/how-to-connect-sso"
],
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "1. Open Microsoft Entra Connect configuration tool on the on-premises server.\n2. Navigate to **Change User Sign In**.\n3. Uncheck **Enable single sign-on**.\n4. Complete the configuration wizard.\n5. In Active Directory, run `Get-AzureADSSOStatus` to verify Seamless SSO shows `\"enable\":false`.\n6. Run `Disable-AzureADSSOForest` with domain admin credentials to remove the AZUREADSSOACC account.",
"Terraform": ""
},
"Recommendation": {
"Text": "Disable **Seamless SSO** in hybrid environments where modern devices support *Primary Refresh Token (PRT)*. Regularly audit Entra Connect settings and verify that the AZUREADSSOACC computer account is removed from Active Directory.",
"Url": "https://hub.prowler.com/check/entra_seamless_sso_disabled"
}
},
"Categories": [
"e3"
],
"DependsOn": [],
"RelatedTo": [
"entra_password_hash_sync_enabled"
],
"Notes": "Applies only to hybrid Microsoft Entra deployments using Entra Connect sync. The check reads the seamless_sso_enabled flag from the directory on-premises synchronization settings via Microsoft Graph API."
}
@@ -0,0 +1,82 @@
from typing import List
from prowler.lib.check.models import Check, CheckReportM365
from prowler.providers.m365.services.entra.entra_client import entra_client
class entra_seamless_sso_disabled(Check):
"""Check that Seamless Single Sign-On (SSO) is disabled for Microsoft Entra hybrid deployments.
Seamless SSO allows users to sign in without typing their passwords when on
corporate devices connected to the corporate network. When an Entra Connect server
is compromised, Seamless SSO can enable lateral movement between on-premises domains
and Entra ID, and it can also be exploited for brute force attacks. Modern devices with
Primary Refresh Token (PRT) support make this feature unnecessary for most organizations.
- PASS: Seamless SSO is disabled or on-premises sync is not enabled (cloud-only).
- FAIL: Seamless SSO is enabled in a hybrid deployment, or cannot verify due to insufficient permissions.
"""
def execute(self) -> List[CheckReportM365]:
"""Execute the Seamless SSO disabled check.
Checks the directory sync settings to determine if Seamless SSO is enabled.
For hybrid environments, this check verifies the actual Seamless SSO configuration
rather than inferring from on-premises sync status.
Returns:
A list of CheckReportM365 objects with the result of the check.
"""
findings = []
# Check if there was an error retrieving directory sync settings
if entra_client.directory_sync_error:
for organization in entra_client.organizations:
report = CheckReportM365(
self.metadata(),
resource=organization,
resource_id=organization.id,
resource_name=organization.name,
)
# Only FAIL for hybrid orgs; cloud-only orgs don't need this permission
if organization.on_premises_sync_enabled:
report.status = "FAIL"
report.status_extended = f"Cannot verify Seamless SSO status for {organization.name}: {entra_client.directory_sync_error}."
else:
report.status = "PASS"
report.status_extended = f"Entra organization {organization.name} is cloud-only (no on-premises sync), Seamless SSO is not applicable."
findings.append(report)
return findings
# Process directory sync settings if available
for sync_settings in entra_client.directory_sync_settings:
report = CheckReportM365(
self.metadata(),
resource=sync_settings,
resource_id=sync_settings.id,
resource_name=f"Directory Sync {sync_settings.id}",
)
if sync_settings.seamless_sso_enabled:
report.status = "FAIL"
report.status_extended = f"Entra directory sync {sync_settings.id} has Seamless SSO enabled, which can be exploited for lateral movement and brute force attacks."
else:
report.status = "PASS"
report.status_extended = f"Entra directory sync {sync_settings.id} has Seamless SSO disabled."
findings.append(report)
# If no directory sync settings and no error, it's a cloud-only tenant
if not entra_client.directory_sync_settings:
for organization in entra_client.organizations:
report = CheckReportM365(
self.metadata(),
resource=organization,
resource_id=organization.id,
resource_name=organization.name,
)
report.status = "PASS"
report.status_extended = f"Entra organization {organization.name} is cloud-only (no on-premises sync), Seamless SSO is not applicable."
findings.append(report)
return findings
@@ -1,9 +1,14 @@
import asyncio
import json
from asyncio import gather
from enum import Enum
from typing import List, Optional
from typing import Dict, List, Optional
from uuid import UUID
from msgraph.generated.models.o_data_errors.o_data_error import ODataError
from msgraph.generated.security.microsoft_graph_security_run_hunting_query.run_hunting_query_post_request_body import (
RunHuntingQueryPostRequestBody,
)
from pydantic.v1 import BaseModel
from prowler.lib.logger import logger
@@ -12,7 +17,33 @@ from prowler.providers.m365.m365_provider import M365Provider
class Entra(M365Service):
"""
Microsoft Entra ID service class.
This class provides methods to retrieve and manage Microsoft Entra ID
security policies and configurations, including authorization policies,
conditional access policies, admin consent policies, groups, organizations,
users, and OAuth application data from Defender XDR.
Attributes:
tenant_domain (str): The tenant domain.
authorization_policy (AuthorizationPolicy): The authorization policy.
conditional_access_policies (dict): Dictionary of conditional access policies.
admin_consent_policy (AdminConsentPolicy): The admin consent policy.
groups (list): List of groups.
organizations (list): List of organizations.
users (dict): Dictionary of users.
user_accounts_status (dict): Dictionary of user account statuses.
oauth_apps (dict): Dictionary of OAuth applications from Defender XDR.
"""
def __init__(self, provider: M365Provider):
"""
Initialize the Entra service client.
Args:
provider: The M365Provider instance for authentication and configuration.
"""
super().__init__(provider)
if self.powershell:
@@ -47,6 +78,8 @@ class Entra(M365Service):
self._get_groups(),
self._get_organization(),
self._get_users(),
self._get_oauth_apps(),
self._get_directory_sync_settings(),
)
)
@@ -56,6 +89,8 @@ class Entra(M365Service):
self.groups = attributes[3]
self.organizations = attributes[4]
self.users = attributes[5]
self.oauth_apps: Optional[Dict[str, OAuthApp]] = attributes[6]
self.directory_sync_settings, self.directory_sync_error = attributes[7]
self.user_accounts_status = {}
if created_loop:
@@ -379,6 +414,57 @@ class Entra(M365Service):
return organizations
async def _get_directory_sync_settings(self):
"""Retrieve on-premises directory synchronization settings.
Fetches the directory synchronization configuration from Microsoft Graph API
to determine the state of synchronization features such as password sync,
device writeback, and other hybrid identity settings.
Returns:
A tuple containing:
- A list of DirectorySyncSettings objects, or an empty list if retrieval fails.
- An error message string if there was an access error, None otherwise.
"""
logger.info("Entra - Getting directory sync settings...")
directory_sync_settings = []
error_message = None
try:
sync_data = await self.client.directory.on_premises_synchronization.get()
for sync in getattr(sync_data, "value", []) or []:
features = getattr(sync, "features", None)
directory_sync_settings.append(
DirectorySyncSettings(
id=sync.id,
password_sync_enabled=getattr(
features, "password_sync_enabled", False
)
or False,
seamless_sso_enabled=getattr(
features, "seamless_sso_enabled", False
)
or False,
)
)
except ODataError as error:
error_code = getattr(error.error, "code", None) if error.error else None
if error_code == "Authorization_RequestDenied":
error_message = "Insufficient privileges to read directory sync settings. Required permission: OnPremDirectorySynchronization.Read.All or OnPremDirectorySynchronization.ReadWrite.All"
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error_message}"
)
else:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
error_message = str(error)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
error_message = str(error)
return directory_sync_settings, error_message
async def _get_users(self):
logger.info("Entra - Getting users...")
users = {}
@@ -461,6 +547,122 @@ class Entra(M365Service):
return registration_details
async def _get_oauth_apps(self) -> Optional[Dict[str, "OAuthApp"]]:
"""
Retrieve OAuth applications from Defender XDR using Advanced Hunting.
This method queries the OAuthAppInfo table to get information about
OAuth applications registered in the tenant, including their permissions
and usage status.
Returns:
Optional[Dict[str, OAuthApp]]: Dictionary of OAuth applications keyed by app ID,
or None if the API call failed (missing permissions or App Governance not enabled).
"""
logger.info("Entra - Getting OAuth apps from Defender XDR...")
oauth_apps: Optional[Dict[str, OAuthApp]] = {}
try:
# Query the OAuthAppInfo table using Advanced Hunting
# The query gets apps with their permissions including usage status
query = """
OAuthAppInfo
| project OAuthAppId, AppName, AppStatus, PrivilegeLevel, Permissions,
ServicePrincipalId, IsAdminConsented, LastUsedTime, AppOrigin
"""
request_body = RunHuntingQueryPostRequestBody(query=query)
result = await self.client.security.microsoft_graph_security_run_hunting_query.post(
request_body
)
if result and result.results:
for row in result.results:
row_data = row.additional_data
raw_app_id = row_data.get("OAuthAppId", "")
# Convert to string in case API returns non-string type
app_id = str(raw_app_id) if raw_app_id else ""
if not app_id:
continue
# Parse the permissions array
# Permissions can be a list of JSON strings or a list of dicts
permissions = []
raw_permissions = row_data.get("Permissions", [])
if raw_permissions:
for perm in raw_permissions:
# Parse JSON string if needed
if isinstance(perm, str):
try:
perm = json.loads(perm)
except json.JSONDecodeError:
continue
if isinstance(perm, dict):
permissions.append(
OAuthAppPermission(
name=str(perm.get("PermissionValue", "")),
target_app_id=str(perm.get("TargetAppId", "")),
target_app_name=str(
perm.get("TargetAppDisplayName", "")
),
permission_type=str(
perm.get("PermissionType", "")
),
classification=str(
perm.get(
"Classification",
perm.get(
"PermissionClassification", ""
),
)
),
privilege_level=str(
perm.get("PrivilegeLevel", "")
),
usage_status=str(perm.get("InUse", "")),
)
)
# Convert values to strings to handle API returning non-string types
raw_service_principal_id = row_data.get("ServicePrincipalId", "")
service_principal_id = (
str(raw_service_principal_id)
if raw_service_principal_id
else ""
)
raw_last_used_time = row_data.get("LastUsedTime")
last_used_time = (
str(raw_last_used_time)
if raw_last_used_time is not None
else None
)
oauth_apps[app_id] = OAuthApp(
id=app_id,
name=str(row_data.get("AppName", "")),
status=str(row_data.get("AppStatus", "")),
privilege_level=str(row_data.get("PrivilegeLevel", "")),
permissions=permissions,
service_principal_id=service_principal_id,
is_admin_consented=bool(
row_data.get("IsAdminConsented", False)
),
last_used_time=last_used_time,
app_origin=str(row_data.get("AppOrigin", "")),
)
except Exception as error:
# Log the error and return None to indicate API failure
# This API requires ThreatHunting.Read.All permission and App Governance to be enabled
logger.warning(
f"Entra - Could not retrieve OAuth apps from Defender XDR. "
f"This requires ThreatHunting.Read.All permission and App Governance enabled. "
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
return None
return oauth_apps
class ConditionalAccessPolicyState(Enum):
ENABLED = "enabled"
@@ -599,6 +801,19 @@ class Organization(BaseModel):
on_premises_sync_enabled: bool
class DirectorySyncSettings(BaseModel):
"""On-premises directory synchronization settings.
Represents the synchronization configuration for a tenant, including feature
flags that control hybrid identity behaviors such as password synchronization
and Seamless SSO.
"""
id: str
password_sync_enabled: bool = False
seamless_sso_enabled: bool = False
class Group(BaseModel):
id: str
name: str
@@ -651,3 +866,53 @@ class AuthPolicyRoles(Enum):
USER = UUID("a0b1b346-4d3e-4e8b-98f8-753987be4970")
GUEST_USER = UUID("10dae51f-b6af-4016-8d66-8c2a99b929b3")
GUEST_USER_ACCESS_RESTRICTED = UUID("2af84b1e-32c8-42b7-82bc-daa82404023b")
class OAuthAppPermission(BaseModel):
"""
Model for OAuth application permission.
Attributes:
name: The permission name.
target_app_id: The target application ID that provides this permission.
target_app_name: The target application display name.
permission_type: The type of permission (Application or Delegated).
classification: Optional plane classification (e.g. Control Plane, Management Plane).
privilege_level: The privilege level (High, Medium, Low).
usage_status: The usage status (InUse or NotInUse).
"""
name: str
target_app_id: str = ""
target_app_name: str = ""
permission_type: str = ""
classification: str = ""
privilege_level: str = ""
usage_status: str = ""
class OAuthApp(BaseModel):
"""
Model for OAuth application from Defender XDR.
Attributes:
id: The application ID.
name: The application display name.
status: The application status (Enabled, Disabled, etc.).
privilege_level: The overall privilege level of the app.
permissions: List of permissions assigned to the app.
service_principal_id: The service principal ID.
is_admin_consented: Whether the app has admin consent.
last_used_time: When the app was last used.
app_origin: Whether the app is internal or external.
"""
id: str
name: str
status: str = ""
privilege_level: str = ""
permissions: List[OAuthAppPermission] = []
service_principal_id: str = ""
is_admin_consented: bool = False
last_used_time: Optional[str] = None
app_origin: str = ""
Whitespace-only changes.
@@ -0,0 +1,646 @@
from unittest import mock
from prowler.lib.check.models import Severity
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
def create_mock_sensor():
"""Create a mock sensor for testing."""
from prowler.providers.m365.services.defenderidentity.defenderidentity_service import (
Sensor,
)
return Sensor(
id="test-sensor-id",
display_name="Test Sensor",
sensor_type="domainControllerIntegrated",
deployment_status="upToDate",
health_status="healthy",
open_health_issues_count=0,
domain_name="example.com",
version="2.200.0.0",
created_date_time="2024-01-01T00:00:00Z",
)
class Test_defenderidentity_health_issues_no_open:
def test_no_health_issues_with_sensors(self):
"""Test when there are no health issues but sensors are deployed: expected PASS."""
defenderidentity_client = mock.MagicMock()
defenderidentity_client.audited_tenant = "audited_tenant"
defenderidentity_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.defenderidentity.defenderidentity_health_issues_no_open.defenderidentity_health_issues_no_open.defenderidentity_client",
new=defenderidentity_client,
),
):
from prowler.providers.m365.services.defenderidentity.defenderidentity_health_issues_no_open.defenderidentity_health_issues_no_open import (
defenderidentity_health_issues_no_open,
)
defenderidentity_client.health_issues = []
defenderidentity_client.sensors = [create_mock_sensor()]
check = defenderidentity_health_issues_no_open()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "No open health issues found in Defender for Identity."
)
assert result[0].resource == {}
assert result[0].resource_name == "Defender for Identity"
assert result[0].resource_id == "defenderIdentity"
def test_no_sensors_deployed(self):
"""Test when no sensors are deployed: expected FAIL."""
defenderidentity_client = mock.MagicMock()
defenderidentity_client.audited_tenant = "audited_tenant"
defenderidentity_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.defenderidentity.defenderidentity_health_issues_no_open.defenderidentity_health_issues_no_open.defenderidentity_client",
new=defenderidentity_client,
),
):
from prowler.providers.m365.services.defenderidentity.defenderidentity_health_issues_no_open.defenderidentity_health_issues_no_open import (
defenderidentity_health_issues_no_open,
)
defenderidentity_client.health_issues = []
defenderidentity_client.sensors = []
check = defenderidentity_health_issues_no_open()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "No sensors deployed" in result[0].status_extended
assert result[0].resource == {}
assert result[0].resource_name == "Defender for Identity"
assert result[0].resource_id == "defenderIdentity"
def test_both_apis_failed(self):
"""Test when both sensors and health_issues APIs fail (None): expected FAIL with permission message."""
defenderidentity_client = mock.MagicMock()
defenderidentity_client.audited_tenant = "audited_tenant"
defenderidentity_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.defenderidentity.defenderidentity_health_issues_no_open.defenderidentity_health_issues_no_open.defenderidentity_client",
new=defenderidentity_client,
),
):
from prowler.providers.m365.services.defenderidentity.defenderidentity_health_issues_no_open.defenderidentity_health_issues_no_open import (
defenderidentity_health_issues_no_open,
)
defenderidentity_client.health_issues = None
defenderidentity_client.sensors = None
check = defenderidentity_health_issues_no_open()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "APIs are not accessible" in result[0].status_extended
assert "SecurityIdentitiesSensors.Read.All" in result[0].status_extended
assert "SecurityIdentitiesHealth.Read.All" in result[0].status_extended
assert result[0].resource == {}
assert result[0].resource_name == "Defender for Identity"
assert result[0].resource_id == "defenderIdentity"
def test_health_issues_api_failed_but_sensors_exist(self):
"""Test when health_issues API fails but sensors exist: expected FAIL with specific permission message."""
defenderidentity_client = mock.MagicMock()
defenderidentity_client.audited_tenant = "audited_tenant"
defenderidentity_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.defenderidentity.defenderidentity_health_issues_no_open.defenderidentity_health_issues_no_open.defenderidentity_client",
new=defenderidentity_client,
),
):
from prowler.providers.m365.services.defenderidentity.defenderidentity_health_issues_no_open.defenderidentity_health_issues_no_open import (
defenderidentity_health_issues_no_open,
)
defenderidentity_client.health_issues = None
defenderidentity_client.sensors = [create_mock_sensor()]
check = defenderidentity_health_issues_no_open()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "Cannot read health issues" in result[0].status_extended
assert "1 sensor(s) deployed" in result[0].status_extended
assert "SecurityIdentitiesHealth.Read.All" in result[0].status_extended
assert result[0].resource == {}
assert result[0].resource_name == "Defender for Identity"
assert result[0].resource_id == "defenderIdentity"
def test_health_issue_resolved(self):
"""Test when a health issue has been resolved (status is not open)."""
defenderidentity_client = mock.MagicMock()
defenderidentity_client.audited_tenant = "audited_tenant"
defenderidentity_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.defenderidentity.defenderidentity_health_issues_no_open.defenderidentity_health_issues_no_open.defenderidentity_client",
new=defenderidentity_client,
),
):
from prowler.providers.m365.services.defenderidentity.defenderidentity_health_issues_no_open.defenderidentity_health_issues_no_open import (
defenderidentity_health_issues_no_open,
)
from prowler.providers.m365.services.defenderidentity.defenderidentity_service import (
HealthIssue,
)
health_issue_id = "test-health-issue-id-1"
health_issue_name = "Test Health Issue Resolved"
defenderidentity_client.sensors = [create_mock_sensor()]
defenderidentity_client.health_issues = [
HealthIssue(
id=health_issue_id,
display_name=health_issue_name,
description="A test health issue that has been resolved",
health_issue_type="sensor",
severity="medium",
status="closed",
created_date_time="2024-01-01T00:00:00Z",
last_modified_date_time="2024-01-02T00:00:00Z",
domain_names=["example.com"],
sensor_dns_names=["sensor1.example.com"],
issue_type_id="test-issue-type-1",
recommendations=["Fix the issue"],
additional_information=[],
)
]
check = defenderidentity_health_issues_no_open()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Defender for Identity sensor health issue {health_issue_name} is resolved."
)
assert result[0].resource_id == health_issue_id
assert result[0].resource_name == health_issue_name
def test_health_issue_open_high_severity(self):
"""Test when a health issue is open with high severity."""
defenderidentity_client = mock.MagicMock()
defenderidentity_client.audited_tenant = "audited_tenant"
defenderidentity_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.defenderidentity.defenderidentity_health_issues_no_open.defenderidentity_health_issues_no_open.defenderidentity_client",
new=defenderidentity_client,
),
):
from prowler.providers.m365.services.defenderidentity.defenderidentity_health_issues_no_open.defenderidentity_health_issues_no_open import (
defenderidentity_health_issues_no_open,
)
from prowler.providers.m365.services.defenderidentity.defenderidentity_service import (
HealthIssue,
)
health_issue_id = "test-health-issue-id-2"
health_issue_name = "Critical Sensor Health Issue"
defenderidentity_client.sensors = [create_mock_sensor()]
defenderidentity_client.health_issues = [
HealthIssue(
id=health_issue_id,
display_name=health_issue_name,
description="A critical health issue that is open",
health_issue_type="global",
severity="high",
status="open",
created_date_time="2024-01-01T00:00:00Z",
last_modified_date_time="2024-01-02T00:00:00Z",
domain_names=["example.com"],
sensor_dns_names=[],
issue_type_id="test-issue-type-2",
recommendations=["Fix the critical issue immediately"],
additional_information=["Additional info about the issue"],
)
]
check = defenderidentity_health_issues_no_open()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Defender for Identity global health issue {health_issue_name} is open with high severity."
)
assert result[0].resource_id == health_issue_id
assert result[0].resource_name == health_issue_name
assert result[0].check_metadata.Severity == Severity.high
def test_health_issue_open_medium_severity(self):
"""Test when a health issue is open with medium severity."""
defenderidentity_client = mock.MagicMock()
defenderidentity_client.audited_tenant = "audited_tenant"
defenderidentity_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.defenderidentity.defenderidentity_health_issues_no_open.defenderidentity_health_issues_no_open.defenderidentity_client",
new=defenderidentity_client,
),
):
from prowler.providers.m365.services.defenderidentity.defenderidentity_health_issues_no_open.defenderidentity_health_issues_no_open import (
defenderidentity_health_issues_no_open,
)
from prowler.providers.m365.services.defenderidentity.defenderidentity_service import (
HealthIssue,
)
health_issue_id = "test-health-issue-id-3"
health_issue_name = "Medium Severity Sensor Issue"
defenderidentity_client.sensors = [create_mock_sensor()]
defenderidentity_client.health_issues = [
HealthIssue(
id=health_issue_id,
display_name=health_issue_name,
description="A medium severity health issue",
health_issue_type="sensor",
severity="medium",
status="open",
created_date_time="2024-01-01T00:00:00Z",
last_modified_date_time="2024-01-02T00:00:00Z",
domain_names=["example.com"],
sensor_dns_names=["sensor2.example.com"],
issue_type_id="test-issue-type-3",
recommendations=["Review and fix the issue"],
additional_information=[],
)
]
check = defenderidentity_health_issues_no_open()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Defender for Identity sensor health issue {health_issue_name} is open with medium severity."
)
assert result[0].resource_id == health_issue_id
assert result[0].resource_name == health_issue_name
assert result[0].check_metadata.Severity == Severity.medium
def test_health_issue_open_low_severity(self):
"""Test when a health issue is open with low severity."""
defenderidentity_client = mock.MagicMock()
defenderidentity_client.audited_tenant = "audited_tenant"
defenderidentity_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.defenderidentity.defenderidentity_health_issues_no_open.defenderidentity_health_issues_no_open.defenderidentity_client",
new=defenderidentity_client,
),
):
from prowler.providers.m365.services.defenderidentity.defenderidentity_health_issues_no_open.defenderidentity_health_issues_no_open import (
defenderidentity_health_issues_no_open,
)
from prowler.providers.m365.services.defenderidentity.defenderidentity_service import (
HealthIssue,
)
health_issue_id = "test-health-issue-id-4"
health_issue_name = "Low Severity Health Issue"
defenderidentity_client.sensors = [create_mock_sensor()]
defenderidentity_client.health_issues = [
HealthIssue(
id=health_issue_id,
display_name=health_issue_name,
description="A low severity health issue",
health_issue_type="global",
severity="low",
status="open",
created_date_time="2024-01-01T00:00:00Z",
last_modified_date_time="2024-01-02T00:00:00Z",
domain_names=["example.com"],
sensor_dns_names=[],
issue_type_id="test-issue-type-4",
recommendations=["Consider fixing the issue"],
additional_information=[],
)
]
check = defenderidentity_health_issues_no_open()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Defender for Identity global health issue {health_issue_name} is open with low severity."
)
assert result[0].resource_id == health_issue_id
assert result[0].resource_name == health_issue_name
assert result[0].check_metadata.Severity == Severity.low
def test_multiple_health_issues_mixed_status(self):
"""Test when there are multiple health issues with different statuses."""
defenderidentity_client = mock.MagicMock()
defenderidentity_client.audited_tenant = "audited_tenant"
defenderidentity_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.defenderidentity.defenderidentity_health_issues_no_open.defenderidentity_health_issues_no_open.defenderidentity_client",
new=defenderidentity_client,
),
):
from prowler.providers.m365.services.defenderidentity.defenderidentity_health_issues_no_open.defenderidentity_health_issues_no_open import (
defenderidentity_health_issues_no_open,
)
from prowler.providers.m365.services.defenderidentity.defenderidentity_service import (
HealthIssue,
)
defenderidentity_client.sensors = [create_mock_sensor()]
defenderidentity_client.health_issues = [
HealthIssue(
id="issue-1",
display_name="Resolved Issue",
description="A resolved health issue",
health_issue_type="sensor",
severity="high",
status="closed",
created_date_time="2024-01-01T00:00:00Z",
last_modified_date_time="2024-01-02T00:00:00Z",
domain_names=["example.com"],
sensor_dns_names=["sensor1.example.com"],
issue_type_id="type-1",
recommendations=[],
additional_information=[],
),
HealthIssue(
id="issue-2",
display_name="Open Issue",
description="An open health issue",
health_issue_type="global",
severity="medium",
status="open",
created_date_time="2024-01-01T00:00:00Z",
last_modified_date_time="2024-01-02T00:00:00Z",
domain_names=["example.com"],
sensor_dns_names=[],
issue_type_id="type-2",
recommendations=["Fix this issue"],
additional_information=[],
),
]
check = defenderidentity_health_issues_no_open()
result = check.execute()
assert len(result) == 2
# First result should be PASS (resolved issue)
assert result[0].status == "PASS"
assert result[0].resource_id == "issue-1"
assert result[0].resource_name == "Resolved Issue"
assert (
result[0].status_extended
== "Defender for Identity sensor health issue Resolved Issue is resolved."
)
# Second result should be FAIL (open issue)
assert result[1].status == "FAIL"
assert result[1].resource_id == "issue-2"
assert result[1].resource_name == "Open Issue"
assert (
result[1].status_extended
== "Defender for Identity global health issue Open Issue is open with medium severity."
)
assert result[1].check_metadata.Severity == Severity.medium
def test_health_issue_with_unknown_type_and_severity(self):
"""Test when health issue has None/unknown type and severity."""
defenderidentity_client = mock.MagicMock()
defenderidentity_client.audited_tenant = "audited_tenant"
defenderidentity_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.defenderidentity.defenderidentity_health_issues_no_open.defenderidentity_health_issues_no_open.defenderidentity_client",
new=defenderidentity_client,
),
):
from prowler.providers.m365.services.defenderidentity.defenderidentity_health_issues_no_open.defenderidentity_health_issues_no_open import (
defenderidentity_health_issues_no_open,
)
from prowler.providers.m365.services.defenderidentity.defenderidentity_service import (
HealthIssue,
)
health_issue_id = "test-health-issue-id-5"
health_issue_name = "Unknown Type Issue"
defenderidentity_client.sensors = [create_mock_sensor()]
defenderidentity_client.health_issues = [
HealthIssue(
id=health_issue_id,
display_name=health_issue_name,
description="A health issue with unknown type and severity",
health_issue_type=None,
severity=None,
status="open",
created_date_time="2024-01-01T00:00:00Z",
last_modified_date_time="2024-01-02T00:00:00Z",
domain_names=[],
sensor_dns_names=[],
issue_type_id="test-issue-type-5",
recommendations=[],
additional_information=[],
)
]
check = defenderidentity_health_issues_no_open()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Defender for Identity unknown health issue {health_issue_name} is open with unknown severity."
)
assert result[0].resource_id == health_issue_id
assert result[0].resource_name == health_issue_name
def test_health_issue_status_case_insensitive(self):
"""Test that status comparison is case insensitive (OPEN vs open)."""
defenderidentity_client = mock.MagicMock()
defenderidentity_client.audited_tenant = "audited_tenant"
defenderidentity_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.defenderidentity.defenderidentity_health_issues_no_open.defenderidentity_health_issues_no_open.defenderidentity_client",
new=defenderidentity_client,
),
):
from prowler.providers.m365.services.defenderidentity.defenderidentity_health_issues_no_open.defenderidentity_health_issues_no_open import (
defenderidentity_health_issues_no_open,
)
from prowler.providers.m365.services.defenderidentity.defenderidentity_service import (
HealthIssue,
)
health_issue_id = "test-health-issue-id-6"
health_issue_name = "Uppercase Status Issue"
defenderidentity_client.sensors = [create_mock_sensor()]
defenderidentity_client.health_issues = [
HealthIssue(
id=health_issue_id,
display_name=health_issue_name,
description="A health issue with uppercase OPEN status",
health_issue_type="sensor",
severity="high",
status="OPEN",
created_date_time="2024-01-01T00:00:00Z",
last_modified_date_time="2024-01-02T00:00:00Z",
domain_names=["example.com"],
sensor_dns_names=["sensor.example.com"],
issue_type_id="test-issue-type-6",
recommendations=["Fix the issue"],
additional_information=[],
)
]
check = defenderidentity_health_issues_no_open()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"Defender for Identity sensor health issue {health_issue_name} is open with high severity."
)
assert result[0].resource_id == health_issue_id
def test_health_issue_with_empty_status(self):
"""Test when health issue has empty/None status (treated as not open)."""
defenderidentity_client = mock.MagicMock()
defenderidentity_client.audited_tenant = "audited_tenant"
defenderidentity_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.defenderidentity.defenderidentity_health_issues_no_open.defenderidentity_health_issues_no_open.defenderidentity_client",
new=defenderidentity_client,
),
):
from prowler.providers.m365.services.defenderidentity.defenderidentity_health_issues_no_open.defenderidentity_health_issues_no_open import (
defenderidentity_health_issues_no_open,
)
from prowler.providers.m365.services.defenderidentity.defenderidentity_service import (
HealthIssue,
)
health_issue_id = "test-health-issue-id-7"
health_issue_name = "Empty Status Issue"
defenderidentity_client.sensors = [create_mock_sensor()]
defenderidentity_client.health_issues = [
HealthIssue(
id=health_issue_id,
display_name=health_issue_name,
description="A health issue with empty status",
health_issue_type="global",
severity="medium",
status=None,
created_date_time="2024-01-01T00:00:00Z",
last_modified_date_time="2024-01-02T00:00:00Z",
domain_names=[],
sensor_dns_names=[],
issue_type_id="test-issue-type-7",
recommendations=[],
additional_information=[],
)
]
check = defenderidentity_health_issues_no_open()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"Defender for Identity global health issue {health_issue_name} is resolved."
)
assert result[0].resource_id == health_issue_id
assert result[0].resource_name == health_issue_name
Whitespace-only changes.
@@ -0,0 +1,218 @@
from unittest import mock
from prowler.providers.m365.services.defenderxdr.defenderxdr_service import (
PendingCAMApproval,
)
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
class Test_defenderxdr_critical_asset_management_pending_approvals:
"""Tests for the defenderxdr_critical_asset_management_pending_approvals check."""
def test_api_failed_missing_permission(self):
"""Test FAIL when API call fails (None): missing ThreatHunting.Read.All permission."""
defenderxdr_client = mock.MagicMock()
defenderxdr_client.audited_tenant = "audited_tenant"
defenderxdr_client.audited_domain = DOMAIN
defenderxdr_client.pending_cam_approvals = None
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.defenderxdr.defenderxdr_critical_asset_management_pending_approvals.defenderxdr_critical_asset_management_pending_approvals.defenderxdr_client",
new=defenderxdr_client,
),
):
from prowler.providers.m365.services.defenderxdr.defenderxdr_critical_asset_management_pending_approvals.defenderxdr_critical_asset_management_pending_approvals import (
defenderxdr_critical_asset_management_pending_approvals,
)
check = defenderxdr_critical_asset_management_pending_approvals()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
"Unable to query Critical Asset Management" in result[0].status_extended
)
assert "ThreatHunting.Read.All" in result[0].status_extended
assert result[0].resource_id == "criticalAssetManagement"
def test_no_pending_approvals_pass(self):
"""Test PASS scenario when there are no pending CAM approvals."""
defenderxdr_client = mock.MagicMock()
defenderxdr_client.audited_tenant = "audited_tenant"
defenderxdr_client.audited_domain = DOMAIN
defenderxdr_client.pending_cam_approvals = []
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.defenderxdr.defenderxdr_critical_asset_management_pending_approvals.defenderxdr_critical_asset_management_pending_approvals.defenderxdr_client",
new=defenderxdr_client,
),
):
from prowler.providers.m365.services.defenderxdr.defenderxdr_critical_asset_management_pending_approvals.defenderxdr_critical_asset_management_pending_approvals import (
defenderxdr_critical_asset_management_pending_approvals,
)
check = defenderxdr_critical_asset_management_pending_approvals()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "No pending approvals for Critical Asset Management classifications are found."
)
assert result[0].resource_name == "Critical Asset Management"
assert result[0].resource_id == "criticalAssetManagement"
assert result[0].resource == {}
def test_single_pending_approval_fail(self):
"""Test FAIL scenario when there is one pending CAM approval."""
defenderxdr_client = mock.MagicMock()
defenderxdr_client.audited_tenant = "audited_tenant"
defenderxdr_client.audited_domain = DOMAIN
defenderxdr_client.pending_cam_approvals = [
PendingCAMApproval(
classification="HighValue",
pending_count=2,
assets=["server-01", "server-02"],
)
]
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.defenderxdr.defenderxdr_critical_asset_management_pending_approvals.defenderxdr_critical_asset_management_pending_approvals.defenderxdr_client",
new=defenderxdr_client,
),
):
from prowler.providers.m365.services.defenderxdr.defenderxdr_critical_asset_management_pending_approvals.defenderxdr_critical_asset_management_pending_approvals import (
defenderxdr_critical_asset_management_pending_approvals,
)
check = defenderxdr_critical_asset_management_pending_approvals()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Critical Asset Management classification 'HighValue' has 2 asset(s) pending approval: server-01, server-02."
)
assert result[0].resource_name == "CAM Classification: HighValue"
assert result[0].resource_id == "cam/HighValue"
assert (
result[0].resource == defenderxdr_client.pending_cam_approvals[0].dict()
)
def test_multiple_pending_approvals_fail(self):
"""Test FAIL scenario when there are multiple pending CAM approvals."""
defenderxdr_client = mock.MagicMock()
defenderxdr_client.audited_tenant = "audited_tenant"
defenderxdr_client.audited_domain = DOMAIN
defenderxdr_client.pending_cam_approvals = [
PendingCAMApproval(
classification="HighValue",
pending_count=1,
assets=["server-01"],
),
PendingCAMApproval(
classification="Critical",
pending_count=3,
assets=["db-01", "db-02", "db-03"],
),
]
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.defenderxdr.defenderxdr_critical_asset_management_pending_approvals.defenderxdr_critical_asset_management_pending_approvals.defenderxdr_client",
new=defenderxdr_client,
),
):
from prowler.providers.m365.services.defenderxdr.defenderxdr_critical_asset_management_pending_approvals.defenderxdr_critical_asset_management_pending_approvals import (
defenderxdr_critical_asset_management_pending_approvals,
)
check = defenderxdr_critical_asset_management_pending_approvals()
result = check.execute()
assert len(result) == 2
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Critical Asset Management classification 'HighValue' has 1 asset(s) pending approval: server-01."
)
assert result[0].resource_name == "CAM Classification: HighValue"
assert result[0].resource_id == "cam/HighValue"
assert result[1].status == "FAIL"
assert (
result[1].status_extended
== "Critical Asset Management classification 'Critical' has 3 asset(s) pending approval: db-01, db-02, db-03."
)
assert result[1].resource_name == "CAM Classification: Critical"
assert result[1].resource_id == "cam/Critical"
def test_pending_approval_with_more_than_five_assets_fail(self):
"""Test FAIL scenario with more than 5 assets to verify truncation."""
defenderxdr_client = mock.MagicMock()
defenderxdr_client.audited_tenant = "audited_tenant"
defenderxdr_client.audited_domain = DOMAIN
defenderxdr_client.pending_cam_approvals = [
PendingCAMApproval(
classification="HighValue",
pending_count=7,
assets=[
"server-01",
"server-02",
"server-03",
"server-04",
"server-05",
"server-06",
"server-07",
],
)
]
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.defenderxdr.defenderxdr_critical_asset_management_pending_approvals.defenderxdr_critical_asset_management_pending_approvals.defenderxdr_client",
new=defenderxdr_client,
),
):
from prowler.providers.m365.services.defenderxdr.defenderxdr_critical_asset_management_pending_approvals.defenderxdr_critical_asset_management_pending_approvals import (
defenderxdr_critical_asset_management_pending_approvals,
)
check = defenderxdr_critical_asset_management_pending_approvals()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Critical Asset Management classification 'HighValue' has 7 asset(s) pending approval: server-01, server-02, server-03, server-04, server-05 and 2 more."
)
assert result[0].resource_name == "CAM Classification: HighValue"
assert result[0].resource_id == "cam/HighValue"
@@ -0,0 +1,375 @@
from unittest import mock
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
class Test_defenderxdr_endpoint_privileged_user_exposed_credentials:
"""Tests for the defenderxdr_endpoint_privileged_user_exposed_credentials check."""
def test_mde_status_api_failed(self):
"""Test FAIL when MDE status API call fails (None): missing permission."""
defenderxdr_client = mock.MagicMock()
defenderxdr_client.audited_tenant = "audited_tenant"
defenderxdr_client.audited_domain = DOMAIN
defenderxdr_client.mde_status = None
defenderxdr_client.exposed_credentials_privileged_users = []
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.defenderxdr.defenderxdr_endpoint_privileged_user_exposed_credentials.defenderxdr_endpoint_privileged_user_exposed_credentials.defenderxdr_client",
new=defenderxdr_client,
),
):
from prowler.providers.m365.services.defenderxdr.defenderxdr_endpoint_privileged_user_exposed_credentials.defenderxdr_endpoint_privileged_user_exposed_credentials import (
defenderxdr_endpoint_privileged_user_exposed_credentials,
)
check = defenderxdr_endpoint_privileged_user_exposed_credentials()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "Unable to query Microsoft Defender XDR" in result[0].status_extended
assert "ThreatHunting.Read.All" in result[0].status_extended
assert result[0].resource_id == "mdeStatus"
def test_mde_not_enabled(self):
"""Test FAIL when MDE is not enabled - security blind spot."""
defenderxdr_client = mock.MagicMock()
defenderxdr_client.audited_tenant = "audited_tenant"
defenderxdr_client.audited_domain = DOMAIN
defenderxdr_client.mde_status = "not_enabled"
defenderxdr_client.exposed_credentials_privileged_users = []
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.defenderxdr.defenderxdr_endpoint_privileged_user_exposed_credentials.defenderxdr_endpoint_privileged_user_exposed_credentials.defenderxdr_client",
new=defenderxdr_client,
),
):
from prowler.providers.m365.services.defenderxdr.defenderxdr_endpoint_privileged_user_exposed_credentials.defenderxdr_endpoint_privileged_user_exposed_credentials import (
defenderxdr_endpoint_privileged_user_exposed_credentials,
)
check = defenderxdr_endpoint_privileged_user_exposed_credentials()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
"Microsoft Defender for Endpoint is not enabled"
in result[0].status_extended
)
assert "no visibility" in result[0].status_extended
assert result[0].resource_id == "mdeStatus"
def test_mde_no_devices(self):
"""Test PASS when MDE is enabled but no devices are onboarded."""
defenderxdr_client = mock.MagicMock()
defenderxdr_client.audited_tenant = "audited_tenant"
defenderxdr_client.audited_domain = DOMAIN
defenderxdr_client.mde_status = "no_devices"
defenderxdr_client.exposed_credentials_privileged_users = []
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.defenderxdr.defenderxdr_endpoint_privileged_user_exposed_credentials.defenderxdr_endpoint_privileged_user_exposed_credentials.defenderxdr_client",
new=defenderxdr_client,
),
):
from prowler.providers.m365.services.defenderxdr.defenderxdr_endpoint_privileged_user_exposed_credentials.defenderxdr_endpoint_privileged_user_exposed_credentials import (
defenderxdr_endpoint_privileged_user_exposed_credentials,
)
check = defenderxdr_endpoint_privileged_user_exposed_credentials()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert "enabled but no devices are onboarded" in result[0].status_extended
assert "No endpoints to evaluate" in result[0].status_extended
assert result[0].resource_id == "mdeDevices"
def test_exposed_credentials_query_failed(self):
"""Test FAIL when exposed credentials query fails (None)."""
defenderxdr_client = mock.MagicMock()
defenderxdr_client.audited_tenant = "audited_tenant"
defenderxdr_client.audited_domain = DOMAIN
defenderxdr_client.mde_status = "active"
defenderxdr_client.exposed_credentials_privileged_users = None
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.defenderxdr.defenderxdr_endpoint_privileged_user_exposed_credentials.defenderxdr_endpoint_privileged_user_exposed_credentials.defenderxdr_client",
new=defenderxdr_client,
),
):
from prowler.providers.m365.services.defenderxdr.defenderxdr_endpoint_privileged_user_exposed_credentials.defenderxdr_endpoint_privileged_user_exposed_credentials import (
defenderxdr_endpoint_privileged_user_exposed_credentials,
)
check = defenderxdr_endpoint_privileged_user_exposed_credentials()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
"Unable to query Security Exposure Management"
in result[0].status_extended
)
assert result[0].resource_id == "exposedCredentials"
def test_no_exposed_credentials(self):
"""Test PASS when no privileged users have exposed credentials."""
defenderxdr_client = mock.MagicMock()
defenderxdr_client.audited_tenant = "audited_tenant"
defenderxdr_client.audited_domain = DOMAIN
defenderxdr_client.mde_status = "active"
defenderxdr_client.exposed_credentials_privileged_users = []
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.defenderxdr.defenderxdr_endpoint_privileged_user_exposed_credentials.defenderxdr_endpoint_privileged_user_exposed_credentials.defenderxdr_client",
new=defenderxdr_client,
),
):
from prowler.providers.m365.services.defenderxdr.defenderxdr_endpoint_privileged_user_exposed_credentials.defenderxdr_endpoint_privileged_user_exposed_credentials import (
defenderxdr_endpoint_privileged_user_exposed_credentials,
)
check = defenderxdr_endpoint_privileged_user_exposed_credentials()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
"No exposed credentials found for privileged users"
in result[0].status_extended
)
assert result[0].resource_name == "Defender XDR Exposure Management"
assert result[0].resource_id == "exposedCredentials"
def test_single_exposed_credential_with_credential_type(self):
"""Test FAIL when a privileged user has exposed credentials with type."""
defenderxdr_client = mock.MagicMock()
defenderxdr_client.audited_tenant = "audited_tenant"
defenderxdr_client.audited_domain = DOMAIN
defenderxdr_client.mde_status = "active"
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.defenderxdr.defenderxdr_endpoint_privileged_user_exposed_credentials.defenderxdr_endpoint_privileged_user_exposed_credentials.defenderxdr_client",
new=defenderxdr_client,
),
):
from prowler.providers.m365.services.defenderxdr.defenderxdr_endpoint_privileged_user_exposed_credentials.defenderxdr_endpoint_privileged_user_exposed_credentials import (
defenderxdr_endpoint_privileged_user_exposed_credentials,
)
from prowler.providers.m365.services.defenderxdr.defenderxdr_service import (
ExposedCredentialPrivilegedUser,
)
exposed_user = ExposedCredentialPrivilegedUser(
edge_id="edge-123",
source_node_id="device-456",
source_node_name="WORKSTATION01",
source_node_label="device",
target_node_id="user-789",
target_node_name="admin@contoso.com",
target_node_label="user",
credential_type="CLI secret",
target_categories=["PrivilegedEntraIdRole"],
)
defenderxdr_client.exposed_credentials_privileged_users = [exposed_user]
check = defenderxdr_endpoint_privileged_user_exposed_credentials()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "admin@contoso.com" in result[0].status_extended
assert "CLI secret" in result[0].status_extended
assert "WORKSTATION01" in result[0].status_extended
assert result[0].resource_name == "admin@contoso.com"
assert result[0].resource_id == "user-789"
def test_single_exposed_credential_without_credential_type(self):
"""Test FAIL when a privileged user has exposed credentials without type."""
defenderxdr_client = mock.MagicMock()
defenderxdr_client.audited_tenant = "audited_tenant"
defenderxdr_client.audited_domain = DOMAIN
defenderxdr_client.mde_status = "active"
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.defenderxdr.defenderxdr_endpoint_privileged_user_exposed_credentials.defenderxdr_endpoint_privileged_user_exposed_credentials.defenderxdr_client",
new=defenderxdr_client,
),
):
from prowler.providers.m365.services.defenderxdr.defenderxdr_endpoint_privileged_user_exposed_credentials.defenderxdr_endpoint_privileged_user_exposed_credentials import (
defenderxdr_endpoint_privileged_user_exposed_credentials,
)
from prowler.providers.m365.services.defenderxdr.defenderxdr_service import (
ExposedCredentialPrivilegedUser,
)
exposed_user = ExposedCredentialPrivilegedUser(
edge_id="edge-123",
source_node_id="device-456",
source_node_name="WORKSTATION01",
source_node_label="device",
target_node_id="user-789",
target_node_name="admin@contoso.com",
target_node_label="user",
credential_type=None,
target_categories=["PrivilegedEntraIdRole"],
)
defenderxdr_client.exposed_credentials_privileged_users = [exposed_user]
check = defenderxdr_endpoint_privileged_user_exposed_credentials()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "admin@contoso.com" in result[0].status_extended
assert "WORKSTATION01" in result[0].status_extended
assert result[0].resource_name == "admin@contoso.com"
assert result[0].resource_id == "user-789"
def test_multiple_exposed_credentials(self):
"""Test FAIL for multiple privileged users with exposed credentials."""
defenderxdr_client = mock.MagicMock()
defenderxdr_client.audited_tenant = "audited_tenant"
defenderxdr_client.audited_domain = DOMAIN
defenderxdr_client.mde_status = "active"
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.defenderxdr.defenderxdr_endpoint_privileged_user_exposed_credentials.defenderxdr_endpoint_privileged_user_exposed_credentials.defenderxdr_client",
new=defenderxdr_client,
),
):
from prowler.providers.m365.services.defenderxdr.defenderxdr_endpoint_privileged_user_exposed_credentials.defenderxdr_endpoint_privileged_user_exposed_credentials import (
defenderxdr_endpoint_privileged_user_exposed_credentials,
)
from prowler.providers.m365.services.defenderxdr.defenderxdr_service import (
ExposedCredentialPrivilegedUser,
)
exposed_user_1 = ExposedCredentialPrivilegedUser(
edge_id="edge-123",
source_node_id="device-456",
source_node_name="WORKSTATION01",
source_node_label="device",
target_node_id="user-789",
target_node_name="admin@contoso.com",
target_node_label="user",
credential_type="CLI secret",
target_categories=["PrivilegedEntraIdRole"],
)
exposed_user_2 = ExposedCredentialPrivilegedUser(
edge_id="edge-456",
source_node_id="device-789",
source_node_name="SERVER01",
source_node_label="device",
target_node_id="user-012",
target_node_name="globaladmin@contoso.com",
target_node_label="user",
credential_type="user cookie",
target_categories=["PrivilegedEntraIdRole", "privileged"],
)
defenderxdr_client.exposed_credentials_privileged_users = [
exposed_user_1,
exposed_user_2,
]
check = defenderxdr_endpoint_privileged_user_exposed_credentials()
result = check.execute()
assert len(result) == 2
assert result[0].status == "FAIL"
assert result[0].resource_name == "admin@contoso.com"
assert result[1].status == "FAIL"
assert result[1].resource_name == "globaladmin@contoso.com"
def test_exposed_credential_uses_edge_id_when_target_node_id_missing(self):
"""Test that edge_id is used as resource_id when target_node_id is empty."""
defenderxdr_client = mock.MagicMock()
defenderxdr_client.audited_tenant = "audited_tenant"
defenderxdr_client.audited_domain = DOMAIN
defenderxdr_client.mde_status = "active"
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.defenderxdr.defenderxdr_endpoint_privileged_user_exposed_credentials.defenderxdr_endpoint_privileged_user_exposed_credentials.defenderxdr_client",
new=defenderxdr_client,
),
):
from prowler.providers.m365.services.defenderxdr.defenderxdr_endpoint_privileged_user_exposed_credentials.defenderxdr_endpoint_privileged_user_exposed_credentials import (
defenderxdr_endpoint_privileged_user_exposed_credentials,
)
from prowler.providers.m365.services.defenderxdr.defenderxdr_service import (
ExposedCredentialPrivilegedUser,
)
exposed_user = ExposedCredentialPrivilegedUser(
edge_id="edge-fallback-123",
source_node_id="device-456",
source_node_name="WORKSTATION01",
source_node_label="device",
target_node_id="",
target_node_name="admin@contoso.com",
target_node_label="user",
credential_type="sensitive token",
target_categories=["PrivilegedEntraIdRole"],
)
defenderxdr_client.exposed_credentials_privileged_users = [exposed_user]
check = defenderxdr_endpoint_privileged_user_exposed_credentials()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert result[0].resource_id == "edge-fallback-123"
assert result[0].resource_name == "admin@contoso.com"
@@ -0,0 +1,895 @@
from unittest import mock
from uuid import uuid4
from prowler.providers.m365.services.entra.entra_service import (
OAuthApp,
OAuthAppPermission,
)
from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider
class Test_entra_app_registration_no_unused_privileged_permissions:
def test_no_oauth_apps(self):
"""No OAuth apps registered in tenant (empty dict): expected PASS."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions import (
entra_app_registration_no_unused_privileged_permissions,
)
entra_client.oauth_apps = {}
check = entra_app_registration_no_unused_privileged_permissions()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "No OAuth applications are registered in the tenant."
)
assert result[0].resource == {}
assert result[0].resource_name == "OAuth Applications"
assert result[0].resource_id == "oauthApps"
def test_no_oauth_apps_none(self):
"""OAuth apps is None (App Governance not enabled): expected FAIL."""
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions import (
entra_app_registration_no_unused_privileged_permissions,
)
entra_client.oauth_apps = None
check = entra_app_registration_no_unused_privileged_permissions()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "OAuth App Governance data is unavailable. Enable App Governance in Microsoft Defender for Cloud Apps and grant ThreatHunting.Read.All to evaluate unused privileged permissions."
)
assert result[0].resource == {}
assert result[0].resource_name == "OAuth Applications"
assert result[0].resource_id == "oauthApps"
def test_app_no_permissions(self):
"""App with no permissions: expected PASS."""
app_id = str(uuid4())
app_name = "Test App No Permissions"
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions import (
entra_app_registration_no_unused_privileged_permissions,
)
entra_client.oauth_apps = {
app_id: OAuthApp(
id=app_id,
name=app_name,
status="Enabled",
privilege_level="Low",
permissions=[],
service_principal_id=str(uuid4()),
is_admin_consented=False,
last_used_time=None,
app_origin="Internal",
)
}
check = entra_app_registration_no_unused_privileged_permissions()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"App registration {app_name} has no unused privileged permissions."
)
assert result[0].resource_name == app_name
assert result[0].resource_id == app_id
def test_app_all_permissions_in_use(self):
"""App with all privileged permissions in use: expected PASS."""
app_id = str(uuid4())
app_name = "Test App All In Use"
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions import (
entra_app_registration_no_unused_privileged_permissions,
)
entra_client.oauth_apps = {
app_id: OAuthApp(
id=app_id,
name=app_name,
status="Enabled",
privilege_level="High",
permissions=[
OAuthAppPermission(
name="Mail.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="InUse",
),
OAuthAppPermission(
name="User.Read.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="InUse",
),
],
service_principal_id=str(uuid4()),
is_admin_consented=True,
last_used_time="2024-01-15T10:30:00Z",
app_origin="Internal",
)
}
check = entra_app_registration_no_unused_privileged_permissions()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"App registration {app_name} has no unused privileged permissions."
)
assert result[0].resource_name == app_name
assert result[0].resource_id == app_id
def test_app_low_privilege_unused(self):
"""App with unused low privilege permissions (not high): expected PASS."""
app_id = str(uuid4())
app_name = "Test App Low Privilege Unused"
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions import (
entra_app_registration_no_unused_privileged_permissions,
)
entra_client.oauth_apps = {
app_id: OAuthApp(
id=app_id,
name=app_name,
status="Enabled",
privilege_level="Low",
permissions=[
OAuthAppPermission(
name="User.Read",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Delegated",
privilege_level="Low",
usage_status="NotInUse",
),
OAuthAppPermission(
name="openid",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Delegated",
privilege_level="Low",
usage_status="NotInUse",
),
],
service_principal_id=str(uuid4()),
is_admin_consented=False,
last_used_time=None,
app_origin="External",
)
}
check = entra_app_registration_no_unused_privileged_permissions()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"App registration {app_name} has no unused privileged permissions."
)
assert result[0].resource_name == app_name
assert result[0].resource_id == app_id
def test_app_medium_privilege_unused(self):
"""App with unused medium privilege permissions (not high): expected PASS."""
app_id = str(uuid4())
app_name = "Test App Medium Privilege Unused"
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions import (
entra_app_registration_no_unused_privileged_permissions,
)
entra_client.oauth_apps = {
app_id: OAuthApp(
id=app_id,
name=app_name,
status="Enabled",
privilege_level="Medium",
permissions=[
OAuthAppPermission(
name="Files.Read",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Delegated",
privilege_level="Medium",
usage_status="NotInUse",
),
],
service_principal_id=str(uuid4()),
is_admin_consented=False,
last_used_time=None,
app_origin="External",
)
}
check = entra_app_registration_no_unused_privileged_permissions()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"App registration {app_name} has no unused privileged permissions."
)
assert result[0].resource_name == app_name
assert result[0].resource_id == app_id
def test_app_one_unused_high_privilege_permission(self):
"""App with one unused high privilege permission: expected FAIL."""
app_id = str(uuid4())
app_name = "Test App One Unused High"
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions import (
entra_app_registration_no_unused_privileged_permissions,
)
entra_client.oauth_apps = {
app_id: OAuthApp(
id=app_id,
name=app_name,
status="Enabled",
privilege_level="High",
permissions=[
OAuthAppPermission(
name="Mail.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="NotInUse",
),
OAuthAppPermission(
name="User.Read",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Delegated",
privilege_level="Low",
usage_status="InUse",
),
],
service_principal_id=str(uuid4()),
is_admin_consented=True,
last_used_time="2024-01-15T10:30:00Z",
app_origin="Internal",
)
}
check = entra_app_registration_no_unused_privileged_permissions()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"App registration {app_name} has 1 unused privileged permission(s): Mail.ReadWrite.All."
)
assert result[0].resource_name == app_name
assert result[0].resource_id == app_id
def test_app_multiple_unused_high_privilege_permissions(self):
"""App with multiple unused high privilege permissions: expected FAIL."""
app_id = str(uuid4())
app_name = "Test App Multiple Unused High"
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions import (
entra_app_registration_no_unused_privileged_permissions,
)
entra_client.oauth_apps = {
app_id: OAuthApp(
id=app_id,
name=app_name,
status="Enabled",
privilege_level="High",
permissions=[
OAuthAppPermission(
name="Mail.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="NotInUse",
),
OAuthAppPermission(
name="Directory.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="NotInUse",
),
OAuthAppPermission(
name="User.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="NotInUse",
),
],
service_principal_id=str(uuid4()),
is_admin_consented=True,
last_used_time="2024-01-15T10:30:00Z",
app_origin="External",
)
}
check = entra_app_registration_no_unused_privileged_permissions()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"App registration {app_name} has 3 unused privileged permission(s): Mail.ReadWrite.All, Directory.ReadWrite.All, User.ReadWrite.All."
)
assert result[0].resource_name == app_name
assert result[0].resource_id == app_id
def test_app_more_than_five_unused_high_privilege_permissions(self):
"""App with more than 5 unused high privilege permissions: expected FAIL with truncated list."""
app_id = str(uuid4())
app_name = "Test App Many Unused High"
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions import (
entra_app_registration_no_unused_privileged_permissions,
)
entra_client.oauth_apps = {
app_id: OAuthApp(
id=app_id,
name=app_name,
status="Enabled",
privilege_level="High",
permissions=[
OAuthAppPermission(
name="Mail.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="NotInUse",
),
OAuthAppPermission(
name="Directory.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="NotInUse",
),
OAuthAppPermission(
name="User.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="NotInUse",
),
OAuthAppPermission(
name="Group.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="NotInUse",
),
OAuthAppPermission(
name="Sites.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="NotInUse",
),
OAuthAppPermission(
name="RoleManagement.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="NotInUse",
),
OAuthAppPermission(
name="Application.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="NotInUse",
),
],
service_principal_id=str(uuid4()),
is_admin_consented=True,
last_used_time="2024-01-15T10:30:00Z",
app_origin="External",
)
}
check = entra_app_registration_no_unused_privileged_permissions()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"App registration {app_name} has 7 unused privileged permission(s): Mail.ReadWrite.All, Directory.ReadWrite.All, User.ReadWrite.All, Group.ReadWrite.All, Sites.ReadWrite.All (and 2 more)."
)
assert result[0].resource_name == app_name
assert result[0].resource_id == app_id
def test_app_unused_with_not_in_use_status(self):
"""App with unused permission using 'not_in_use' status variant: expected FAIL."""
app_id = str(uuid4())
app_name = "Test App NotInUse Variant"
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions import (
entra_app_registration_no_unused_privileged_permissions,
)
entra_client.oauth_apps = {
app_id: OAuthApp(
id=app_id,
name=app_name,
status="Enabled",
privilege_level="High",
permissions=[
OAuthAppPermission(
name="Mail.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="not_in_use",
),
],
service_principal_id=str(uuid4()),
is_admin_consented=True,
last_used_time=None,
app_origin="Internal",
)
}
check = entra_app_registration_no_unused_privileged_permissions()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"App registration {app_name} has 1 unused privileged permission(s): Mail.ReadWrite.All."
)
assert result[0].resource_name == app_name
assert result[0].resource_id == app_id
def test_multiple_apps_mixed_results(self):
"""Multiple apps with mixed results: one PASS and one FAIL."""
app_id_pass = str(uuid4())
app_name_pass = "Test App Pass"
app_id_fail = str(uuid4())
app_name_fail = "Test App Fail"
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions import (
entra_app_registration_no_unused_privileged_permissions,
)
entra_client.oauth_apps = {
app_id_pass: OAuthApp(
id=app_id_pass,
name=app_name_pass,
status="Enabled",
privilege_level="High",
permissions=[
OAuthAppPermission(
name="Mail.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="InUse",
),
],
service_principal_id=str(uuid4()),
is_admin_consented=True,
last_used_time="2024-01-15T10:30:00Z",
app_origin="Internal",
),
app_id_fail: OAuthApp(
id=app_id_fail,
name=app_name_fail,
status="Enabled",
privilege_level="High",
permissions=[
OAuthAppPermission(
name="Directory.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="NotInUse",
),
],
service_principal_id=str(uuid4()),
is_admin_consented=True,
last_used_time="2024-01-15T10:30:00Z",
app_origin="External",
),
}
check = entra_app_registration_no_unused_privileged_permissions()
result = check.execute()
assert len(result) == 2
# Find results by app ID
result_pass = next(r for r in result if r.resource_id == app_id_pass)
result_fail = next(r for r in result if r.resource_id == app_id_fail)
assert result_pass.status == "PASS"
assert (
result_pass.status_extended
== f"App registration {app_name_pass} has no unused privileged permissions."
)
assert result_pass.resource_name == app_name_pass
assert result_fail.status == "FAIL"
assert (
result_fail.status_extended
== f"App registration {app_name_fail} has 1 unused privileged permission(s): Directory.ReadWrite.All."
)
assert result_fail.resource_name == app_name_fail
def test_app_mixed_privilege_levels_unused(self):
"""App with mixed privilege levels (High and Low) unused: only High triggers FAIL."""
app_id = str(uuid4())
app_name = "Test App Mixed Privileges"
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions import (
entra_app_registration_no_unused_privileged_permissions,
)
entra_client.oauth_apps = {
app_id: OAuthApp(
id=app_id,
name=app_name,
status="Enabled",
privilege_level="High",
permissions=[
OAuthAppPermission(
name="Mail.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="NotInUse",
),
OAuthAppPermission(
name="User.Read",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Delegated",
privilege_level="Low",
usage_status="NotInUse",
),
OAuthAppPermission(
name="Files.Read",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Delegated",
privilege_level="Medium",
usage_status="NotInUse",
),
],
service_principal_id=str(uuid4()),
is_admin_consented=True,
last_used_time="2024-01-15T10:30:00Z",
app_origin="Internal",
)
}
check = entra_app_registration_no_unused_privileged_permissions()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
# Only the High privilege permission should be reported
assert (
result[0].status_extended
== f"App registration {app_name} has 1 unused privileged permission(s): Mail.ReadWrite.All."
)
assert result[0].resource_name == app_name
assert result[0].resource_id == app_id
def test_app_high_privilege_in_use_and_unused(self):
"""App with some high privilege permissions in use and some unused: expected FAIL."""
app_id = str(uuid4())
app_name = "Test App Partial Usage"
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions import (
entra_app_registration_no_unused_privileged_permissions,
)
entra_client.oauth_apps = {
app_id: OAuthApp(
id=app_id,
name=app_name,
status="Enabled",
privilege_level="High",
permissions=[
OAuthAppPermission(
name="Mail.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="InUse",
),
OAuthAppPermission(
name="Directory.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="NotInUse",
),
OAuthAppPermission(
name="User.ReadWrite.All",
target_app_id="00000003-0000-0000-c000-000000000000",
target_app_name="Microsoft Graph",
permission_type="Application",
privilege_level="High",
usage_status="InUse",
),
],
service_principal_id=str(uuid4()),
is_admin_consented=True,
last_used_time="2024-01-15T10:30:00Z",
app_origin="Internal",
)
}
check = entra_app_registration_no_unused_privileged_permissions()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"App registration {app_name} has 1 unused privileged permission(s): Directory.ReadWrite.All."
)
assert result[0].resource_name == app_name
assert result[0].resource_id == app_id
def test_app_without_name_uses_id(self):
"""App without a name should use app_id as resource_name."""
app_id = str(uuid4())
entra_client = mock.MagicMock
entra_client.audited_tenant = "audited_tenant"
entra_client.audited_domain = DOMAIN
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_app_registration_no_unused_privileged_permissions.entra_app_registration_no_unused_privileged_permissions import (
entra_app_registration_no_unused_privileged_permissions,
)
entra_client.oauth_apps = {
app_id: OAuthApp(
id=app_id,
name="",
status="Enabled",
privilege_level="Low",
permissions=[],
service_principal_id=str(uuid4()),
is_admin_consented=False,
last_used_time=None,
app_origin="Internal",
)
}
check = entra_app_registration_no_unused_privileged_permissions()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert result[0].resource_name == ""
assert result[0].resource_id == app_id
@@ -0,0 +1,274 @@
from unittest import mock
from prowler.providers.m365.services.entra.entra_service import (
DirectorySyncSettings,
Organization,
)
from tests.providers.m365.m365_fixtures import set_mocked_m365_provider
class Test_entra_seamless_sso_disabled:
def test_seamless_sso_disabled(self):
"""Test PASS when Seamless SSO is disabled in directory sync settings."""
entra_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_seamless_sso_disabled.entra_seamless_sso_disabled.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_seamless_sso_disabled.entra_seamless_sso_disabled import (
entra_seamless_sso_disabled,
)
sync_settings = DirectorySyncSettings(
id="sync-001",
password_sync_enabled=True,
seamless_sso_enabled=False,
)
entra_client.directory_sync_settings = [sync_settings]
entra_client.directory_sync_error = None
entra_client.organizations = []
check = entra_seamless_sso_disabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Entra directory sync sync-001 has Seamless SSO disabled."
)
assert result[0].resource_id == "sync-001"
assert result[0].resource_name == "Directory Sync sync-001"
assert result[0].location == "global"
def test_seamless_sso_enabled(self):
"""Test FAIL when Seamless SSO is enabled in directory sync settings."""
entra_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_seamless_sso_disabled.entra_seamless_sso_disabled.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_seamless_sso_disabled.entra_seamless_sso_disabled import (
entra_seamless_sso_disabled,
)
sync_settings = DirectorySyncSettings(
id="sync-001",
password_sync_enabled=True,
seamless_sso_enabled=True,
)
entra_client.directory_sync_settings = [sync_settings]
entra_client.directory_sync_error = None
entra_client.organizations = []
check = entra_seamless_sso_disabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Entra directory sync sync-001 has Seamless SSO enabled, which can be exploited for lateral movement and brute force attacks."
)
assert result[0].resource_id == "sync-001"
assert result[0].resource_name == "Directory Sync sync-001"
assert result[0].location == "global"
def test_multiple_sync_settings_mixed(self):
"""Test mixed results with multiple directory sync configurations."""
entra_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_seamless_sso_disabled.entra_seamless_sso_disabled.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_seamless_sso_disabled.entra_seamless_sso_disabled import (
entra_seamless_sso_disabled,
)
sync_settings_1 = DirectorySyncSettings(
id="sync-001",
password_sync_enabled=True,
seamless_sso_enabled=True,
)
sync_settings_2 = DirectorySyncSettings(
id="sync-002",
password_sync_enabled=True,
seamless_sso_enabled=False,
)
entra_client.directory_sync_settings = [sync_settings_1, sync_settings_2]
entra_client.directory_sync_error = None
entra_client.organizations = []
check = entra_seamless_sso_disabled()
result = check.execute()
assert len(result) == 2
assert result[0].status == "FAIL"
assert result[0].resource_id == "sync-001"
assert result[1].status == "PASS"
assert result[1].resource_id == "sync-002"
def test_cloud_only_no_sync_settings(self):
"""Test PASS for cloud-only tenant with no directory sync settings."""
entra_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_seamless_sso_disabled.entra_seamless_sso_disabled.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_seamless_sso_disabled.entra_seamless_sso_disabled import (
entra_seamless_sso_disabled,
)
org = Organization(
id="org1",
name="Cloud Only Org",
on_premises_sync_enabled=False,
)
entra_client.directory_sync_settings = []
entra_client.directory_sync_error = None
entra_client.organizations = [org]
check = entra_seamless_sso_disabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Entra organization Cloud Only Org is cloud-only (no on-premises sync), Seamless SSO is not applicable."
)
assert result[0].resource_id == "org1"
assert result[0].resource_name == "Cloud Only Org"
def test_insufficient_permissions_error(self):
"""Test FAIL when there's a permission error reading directory sync settings."""
entra_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_seamless_sso_disabled.entra_seamless_sso_disabled.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_seamless_sso_disabled.entra_seamless_sso_disabled import (
entra_seamless_sso_disabled,
)
org = Organization(
id="org1",
name="Prowler Org",
on_premises_sync_enabled=True,
)
entra_client.directory_sync_settings = []
entra_client.directory_sync_error = "Insufficient privileges to read directory sync settings. Required permission: OnPremDirectorySynchronization.Read.All or OnPremDirectorySynchronization.ReadWrite.All"
entra_client.organizations = [org]
check = entra_seamless_sso_disabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "Cannot verify Seamless SSO status" in result[0].status_extended
assert "Insufficient privileges" in result[0].status_extended
assert (
"OnPremDirectorySynchronization.Read.All" in result[0].status_extended
)
assert result[0].resource_id == "org1"
assert result[0].resource_name == "Prowler Org"
def test_insufficient_permissions_cloud_only_passes(self):
"""Test PASS for cloud-only org even when there's a permission error."""
entra_client = mock.MagicMock()
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_seamless_sso_disabled.entra_seamless_sso_disabled.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_seamless_sso_disabled.entra_seamless_sso_disabled import (
entra_seamless_sso_disabled,
)
# Cloud-only org (on_premises_sync_enabled=False)
org = Organization(
id="org1",
name="Cloud Only Org",
on_premises_sync_enabled=False,
)
entra_client.directory_sync_settings = []
entra_client.directory_sync_error = (
"Insufficient privileges to read directory sync settings."
)
entra_client.organizations = [org]
check = entra_seamless_sso_disabled()
result = check.execute()
# Should PASS because cloud-only orgs don't need this permission
assert len(result) == 1
assert result[0].status == "PASS"
assert "cloud-only" in result[0].status_extended
assert result[0].resource_id == "org1"
def test_empty_everything(self):
"""Test no findings when both sync settings and organizations are empty."""
entra_client = mock.MagicMock()
entra_client.directory_sync_settings = []
entra_client.directory_sync_error = None
entra_client.organizations = []
with (
mock.patch(
"prowler.providers.common.provider.Provider.get_global_provider",
return_value=set_mocked_m365_provider(),
),
mock.patch(
"prowler.providers.m365.services.entra.entra_seamless_sso_disabled.entra_seamless_sso_disabled.entra_client",
new=entra_client,
),
):
from prowler.providers.m365.services.entra.entra_seamless_sso_disabled.entra_seamless_sso_disabled import (
entra_seamless_sso_disabled,
)
check = entra_seamless_sso_disabled()
result = check.execute()
assert len(result) == 0
+1
View File
@@ -6,6 +6,7 @@ All notable changes to the **Prowler UI** are documented in this file.
### 🚀 Added
- OpenStack provider support in the UI [(#10046)](https://github.com/prowler-cloud/prowler/pull/10046)
- PDF report available for the CSA CCM compliance framework [(#10088)](https://github.com/prowler-cloud/prowler/pull/10088)
- CSV and PDF download buttons in compliance views [(#10093)](https://github.com/prowler-cloud/prowler/pull/10093)
@@ -13,6 +13,7 @@ import {
KS8ProviderBadge,
M365ProviderBadge,
MongoDBAtlasProviderBadge,
OpenStackProviderBadge,
OracleCloudProviderBadge,
} from "@/components/icons/providers-badge";
import {
@@ -36,6 +37,7 @@ const PROVIDER_ICON: Record<ProviderType, ReactNode> = {
oraclecloud: <OracleCloudProviderBadge width={18} height={18} />,
mongodbatlas: <MongoDBAtlasProviderBadge width={18} height={18} />,
alibabacloud: <AlibabaCloudProviderBadge width={18} height={18} />,
openstack: <OpenStackProviderBadge width={18} height={18} />,
};
interface AccountsSelectorProps {
@@ -63,6 +63,11 @@ const AlibabaCloudProviderBadge = lazy(() =>
default: m.AlibabaCloudProviderBadge,
})),
);
const OpenStackProviderBadge = lazy(() =>
import("@/components/icons/providers-badge").then((m) => ({
default: m.OpenStackProviderBadge,
})),
);
type IconProps = { width: number; height: number };
@@ -114,6 +119,10 @@ const PROVIDER_DATA: Record<
label: "Alibaba Cloud",
icon: AlibabaCloudProviderBadge,
},
openstack: {
label: "OpenStack",
icon: OpenStackProviderBadge,
},
};
type ProviderTypeSelectorProps = {
@@ -8,6 +8,7 @@ import {
KS8ProviderBadge,
M365ProviderBadge,
MongoDBAtlasProviderBadge,
OpenStackProviderBadge,
OracleCloudProviderBadge,
} from "../icons/providers-badge";
@@ -100,3 +101,12 @@ export const CustomProviderInputAlibabaCloud = () => {
</div>
);
};
export const CustomProviderInputOpenStack = () => {
return (
<div className="flex items-center gap-x-2">
<OpenStackProviderBadge width={25} height={25} />
<p className="text-sm">OpenStack</p>
</div>
);
};
@@ -8,6 +8,7 @@ import {
KS8ProviderBadge,
M365ProviderBadge,
MongoDBAtlasProviderBadge,
OpenStackProviderBadge,
OracleCloudProviderBadge,
} from "@/components/icons/providers-badge";
import { ProviderType } from "@/types";
@@ -23,6 +24,7 @@ export const PROVIDER_ICONS = {
oraclecloud: OracleCloudProviderBadge,
mongodbatlas: MongoDBAtlasProviderBadge,
alibabacloud: AlibabaCloudProviderBadge,
openstack: OpenStackProviderBadge,
} as const;
interface ProviderIconCellProps {
@@ -11,6 +11,7 @@ import { IacProviderBadge } from "./iac-provider-badge";
import { KS8ProviderBadge } from "./ks8-provider-badge";
import { M365ProviderBadge } from "./m365-provider-badge";
import { MongoDBAtlasProviderBadge } from "./mongodbatlas-provider-badge";
import { OpenStackProviderBadge } from "./openstack-provider-badge";
import { OracleCloudProviderBadge } from "./oraclecloud-provider-badge";
export {
@@ -23,6 +24,7 @@ export {
KS8ProviderBadge,
M365ProviderBadge,
MongoDBAtlasProviderBadge,
OpenStackProviderBadge,
OracleCloudProviderBadge,
};
@@ -38,4 +40,5 @@ export const PROVIDER_ICONS: Record<string, FC<IconSvgProps>> = {
"Oracle Cloud Infrastructure": OracleCloudProviderBadge,
"MongoDB Atlas": MongoDBAtlasProviderBadge,
"Alibaba Cloud": AlibabaCloudProviderBadge,
OpenStack: OpenStackProviderBadge,
};
@@ -0,0 +1,29 @@
import * as React from "react";
import { IconSvgProps } from "@/types";
export const OpenStackProviderBadge: React.FC<IconSvgProps> = ({
size,
width,
height,
...props
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
fill="none"
focusable="false"
height={size || height}
role="presentation"
viewBox="0 0 256 256"
width={size || width}
{...props}
>
<g fill="none">
<rect width="256" height="256" fill="#f4f2ed" rx="60" />
<g transform="translate(48 48) scale(2.5)" fill="#da1a32">
<path d="M58.054.68H5.946C2.676.68 0 3.356 0 6.626V20.64h14.452v-2.3c0-1.776 1.44-3.215 3.215-3.215h28.665c1.776 0 3.215 1.44 3.215 3.215v2.3H64v-14A5.97 5.97 0 0 0 58.054.68zm-8.506 44.97c0 1.776-1.44 3.215-3.215 3.215H17.67c-1.776 0-3.215-1.44-3.215-3.215v-2.3H0v14.013c0 3.27 2.676 5.946 5.946 5.946h52.108c3.27 0 5.946-2.676 5.946-5.946V43.36H49.548zM0 24.773h14.452v14.452H0zm49.548 0H64v14.452H49.548z" />
</g>
</g>
</svg>
);
@@ -18,6 +18,7 @@ import {
KS8ProviderBadge,
M365ProviderBadge,
MongoDBAtlasProviderBadge,
OpenStackProviderBadge,
OracleCloudProviderBadge,
} from "../icons/providers-badge";
import { FormMessage } from "../ui/form";
@@ -73,6 +74,11 @@ const PROVIDERS = [
label: "Alibaba Cloud",
badge: AlibabaCloudProviderBadge,
},
{
value: "openstack",
label: "OpenStack",
badge: OpenStackProviderBadge,
},
] as const;
interface RadioGroupProviderProps {
@@ -25,6 +25,7 @@ import {
M365ClientSecretCredentials,
MongoDBAtlasCredentials,
OCICredentials,
OpenStackCredentials,
ProviderType,
} from "@/types";
@@ -46,6 +47,7 @@ import { GitHubCredentialsForm } from "./via-credentials/github-credentials-form
import { IacCredentialsForm } from "./via-credentials/iac-credentials-form";
import { KubernetesCredentialsForm } from "./via-credentials/k8s-credentials-form";
import { MongoDBAtlasCredentialsForm } from "./via-credentials/mongodbatlas-credentials-form";
import { OpenStackCredentialsForm } from "./via-credentials/openstack-credentials-form";
import { OracleCloudCredentialsForm } from "./via-credentials/oraclecloud-credentials-form";
type BaseCredentialsFormProps = {
@@ -206,6 +208,11 @@ export const BaseCredentialsForm = ({
}
/>
)}
{providerType === "openstack" && (
<OpenStackCredentialsForm
control={form.control as unknown as Control<OpenStackCredentials>}
/>
)}
<div className="flex w-full justify-end gap-4">
{showBackButton && requiresBackButton(searchParamsObj.get("via")) && (
@@ -72,6 +72,11 @@ const getProviderFieldDetails = (providerType?: ProviderType) => {
label: "Account ID",
placeholder: "e.g. 1234567890123456",
};
case "openstack":
return {
label: "Project ID",
placeholder: "e.g. a1b2c3d4-e5f6-7890-abcd-ef1234567890",
};
default:
return {
label: "Provider UID",
@@ -3,3 +3,4 @@ export * from "./github-credentials-form";
export * from "./iac-credentials-form";
export * from "./k8s-credentials-form";
export * from "./mongodbatlas-credentials-form";
export * from "./openstack-credentials-form";
@@ -0,0 +1,43 @@
import { Control } from "react-hook-form";
import { CustomInput, CustomTextarea } from "@/components/ui/custom";
import { OpenStackCredentials } from "@/types";
export const OpenStackCredentialsForm = ({
control,
}: {
control: Control<OpenStackCredentials>;
}) => {
return (
<>
<div className="flex flex-col">
<div className="text-md text-default-foreground leading-9 font-bold">
Connect via Clouds YAML
</div>
<div className="text-default-500 text-sm">
Please provide your OpenStack clouds.yaml content and the cloud name.
</div>
</div>
<CustomTextarea
control={control}
name="clouds_yaml_content"
label="Clouds YAML Content"
labelPlacement="inside"
placeholder="Paste your clouds.yaml content here"
variant="bordered"
minRows={10}
isRequired
/>
<CustomInput
control={control}
name="clouds_yaml_cloud"
type="text"
label="Cloud Name"
labelPlacement="inside"
placeholder="e.g. mycloud"
variant="bordered"
isRequired
/>
</>
);
};
@@ -8,6 +8,7 @@ import {
KS8ProviderBadge,
M365ProviderBadge,
MongoDBAtlasProviderBadge,
OpenStackProviderBadge,
OracleCloudProviderBadge,
} from "@/components/icons/providers-badge";
import { ProviderType } from "@/types";
@@ -34,6 +35,8 @@ export const getProviderLogo = (provider: ProviderType) => {
return <MongoDBAtlasProviderBadge width={35} height={35} />;
case "alibabacloud":
return <AlibabaCloudProviderBadge width={35} height={35} />;
case "openstack":
return <OpenStackProviderBadge width={35} height={35} />;
default:
return null;
}
@@ -61,6 +64,8 @@ export const getProviderName = (provider: ProviderType): string => {
return "MongoDB Atlas";
case "alibabacloud":
return "Alibaba Cloud";
case "openstack":
return "OpenStack";
default:
return "Unknown Provider";
}
+6
View File
@@ -192,6 +192,12 @@ export const useCredentialsForm = ({
[ProviderCredentialFields.ALIBABACLOUD_ACCESS_KEY_ID]: "",
[ProviderCredentialFields.ALIBABACLOUD_ACCESS_KEY_SECRET]: "",
};
case "openstack":
return {
...baseDefaults,
[ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CONTENT]: "",
[ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CLOUD]: "",
};
default:
return baseDefaults;
}
+4
View File
@@ -30,4 +30,8 @@ export const PROVIDER_CREDENTIALS_ERROR_MAPPING: Record<string, string> = {
ProviderCredentialFields.SERVICE_ACCOUNT_KEY,
[ErrorPointers.ATLAS_PUBLIC_KEY]: ProviderCredentialFields.ATLAS_PUBLIC_KEY,
[ErrorPointers.ATLAS_PRIVATE_KEY]: ProviderCredentialFields.ATLAS_PRIVATE_KEY,
[ErrorPointers.OPENSTACK_CLOUDS_YAML_CONTENT]:
ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CONTENT,
[ErrorPointers.OPENSTACK_CLOUDS_YAML_CLOUD]:
ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CLOUD,
};
+5
View File
@@ -58,6 +58,11 @@ export const getProviderHelpText = (provider: string) => {
text: "Need help connecting your Alibaba Cloud account?",
link: "https://goto.prowler.com/provider-alibabacloud",
};
case "openstack":
return {
text: "Need help connecting your OpenStack cloud?",
link: "https://goto.prowler.com/provider-openstack",
};
default:
return {
text: "How to setup a provider?",
@@ -250,6 +250,20 @@ export const buildAlibabaCloudSecret = (
return filterEmptyValues(secret);
};
export const buildOpenStackSecret = (formData: FormData) => {
const secret = {
[ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CONTENT]: getFormValue(
formData,
ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CONTENT,
),
[ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CLOUD]: getFormValue(
formData,
ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CLOUD,
),
};
return filterEmptyValues(secret);
};
export const buildIacSecret = (formData: FormData) => {
const secret = {
[ProviderCredentialFields.REPOSITORY_URL]: getFormValue(
@@ -373,6 +387,10 @@ export const buildSecretConfig = (
secret: buildAlibabaCloudSecret(formData, isRole),
};
},
openstack: () => ({
secretType: "static",
secret: buildOpenStackSecret(formData),
}),
};
const builder = secretBuilders[providerType];
@@ -67,6 +67,10 @@ export const ProviderCredentialFields = {
ALIBABACLOUD_ACCESS_KEY_SECRET: "access_key_secret",
ALIBABACLOUD_ROLE_ARN: "role_arn",
ALIBABACLOUD_ROLE_SESSION_NAME: "role_session_name",
// OpenStack fields
OPENSTACK_CLOUDS_YAML_CONTENT: "clouds_yaml_content",
OPENSTACK_CLOUDS_YAML_CLOUD: "clouds_yaml_cloud",
} as const;
// Type for credential field values
@@ -111,6 +115,8 @@ export const ErrorPointers = {
ALIBABACLOUD_ACCESS_KEY_SECRET: "/data/attributes/secret/access_key_secret",
ALIBABACLOUD_ROLE_ARN: "/data/attributes/secret/role_arn",
ALIBABACLOUD_ROLE_SESSION_NAME: "/data/attributes/secret/role_session_name",
OPENSTACK_CLOUDS_YAML_CONTENT: "/data/attributes/secret/clouds_yaml_content",
OPENSTACK_CLOUDS_YAML_CLOUD: "/data/attributes/secret/clouds_yaml_cloud",
} as const;
export type ErrorPointer = (typeof ErrorPointers)[keyof typeof ErrorPointers];
+8 -1
View File
@@ -334,6 +334,12 @@ export type AlibabaCloudCredentialsRole = {
[ProviderCredentialFields.PROVIDER_ID]: string;
};
export type OpenStackCredentials = {
[ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CONTENT]: string;
[ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CLOUD]: string;
[ProviderCredentialFields.PROVIDER_ID]: string;
};
export type CredentialsFormSchema =
| AWSCredentials
| AWSCredentialsRole
@@ -346,7 +352,8 @@ export type CredentialsFormSchema =
| OCICredentials
| MongoDBAtlasCredentials
| AlibabaCloudCredentials
| AlibabaCloudCredentialsRole;
| AlibabaCloudCredentialsRole
| OpenStackCredentials;
export interface SearchParamsProps {
[key: string]: string | string[] | undefined;
+15 -1
View File
@@ -130,6 +130,11 @@ export const addProviderFormSchema = z
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
providerUid: z.string(),
}),
z.object({
providerType: z.literal("openstack"),
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
providerUid: z.string(),
}),
]),
);
@@ -259,7 +264,16 @@ export const addCredentialsFormSchema = (
.string()
.min(1, "Access Key Secret is required"),
}
: {}),
: providerType === "openstack"
? {
[ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CONTENT]:
z
.string()
.min(1, "Clouds YAML content is required"),
[ProviderCredentialFields.OPENSTACK_CLOUDS_YAML_CLOUD]:
z.string().min(1, "Cloud name is required"),
}
: {}),
})
.superRefine((data: Record<string, string | undefined>, ctx) => {
if (providerType === "m365") {
+2
View File
@@ -9,6 +9,7 @@ export const PROVIDER_TYPES = [
"iac",
"oraclecloud",
"alibabacloud",
"openstack",
] as const;
export type ProviderType = (typeof PROVIDER_TYPES)[number];
@@ -24,6 +25,7 @@ export const PROVIDER_DISPLAY_NAMES: Record<ProviderType, string> = {
iac: "Infrastructure as Code",
oraclecloud: "Oracle Cloud Infrastructure",
alibabacloud: "Alibaba Cloud",
openstack: "OpenStack",
};
export function getProviderDisplayName(providerId: string): string {