From 23e51158e0839626eb5137a0dca9c78ceb6b8432 Mon Sep 17 00:00:00 2001 From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> Date: Thu, 19 Feb 2026 16:58:08 +0100 Subject: [PATCH] feat(m365): add defenderidentity_health_issues_no_open security check (#10087) --- .../providers/microsoft365/authentication.mdx | 4 + prowler/CHANGELOG.md | 1 + .../compliance/m365/iso27001_2022_m365.json | 4 +- .../services/defenderidentity/__init__.py | 0 .../defenderidentity_client.py | 6 + .../__init__.py | 0 ...entity_health_issues_no_open.metadata.json | 37 + .../defenderidentity_health_issues_no_open.py | 140 ++++ .../defenderidentity_service.py | 292 ++++++++ .../services/defenderidentity/__init__.py | 0 .../__init__.py | 0 ...nderidentity_health_issues_no_open_test.py | 646 ++++++++++++++++++ 12 files changed, 1129 insertions(+), 1 deletion(-) create mode 100644 prowler/providers/m365/services/defenderidentity/__init__.py create mode 100644 prowler/providers/m365/services/defenderidentity/defenderidentity_client.py create mode 100644 prowler/providers/m365/services/defenderidentity/defenderidentity_health_issues_no_open/__init__.py create mode 100644 prowler/providers/m365/services/defenderidentity/defenderidentity_health_issues_no_open/defenderidentity_health_issues_no_open.metadata.json create mode 100644 prowler/providers/m365/services/defenderidentity/defenderidentity_health_issues_no_open/defenderidentity_health_issues_no_open.py create mode 100644 prowler/providers/m365/services/defenderidentity/defenderidentity_service.py create mode 100644 tests/providers/m365/services/defenderidentity/__init__.py create mode 100644 tests/providers/m365/services/defenderidentity/defenderidentity_health_issues_no_open/__init__.py create mode 100644 tests/providers/m365/services/defenderidentity/defenderidentity_health_issues_no_open/defenderidentity_health_issues_no_open_test.py diff --git a/docs/user-guide/providers/microsoft365/authentication.mdx b/docs/user-guide/providers/microsoft365/authentication.mdx index be1e7c78f0..d94f346787 100644 --- a/docs/user-guide/providers/microsoft365/authentication.mdx +++ b/docs/user-guide/providers/microsoft365/authentication.mdx @@ -42,6 +42,8 @@ When using service principal authentication, add these **Application Permissions - `AuditLog.Read.All`: Required for Entra service. - `Directory.Read.All`: Required for all services. - `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. **External API Permissions:** @@ -106,6 +108,8 @@ 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 - `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) diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index a372096834..7cdc9efdcc 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to the **Prowler SDK** are documented in this file. ### 🚀 Added +- `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) diff --git a/prowler/compliance/m365/iso27001_2022_m365.json b/prowler/compliance/m365/iso27001_2022_m365.json index 3335fe0f19..d6c292dc9a 100644 --- a/prowler/compliance/m365/iso27001_2022_m365.json +++ b/prowler/compliance/m365/iso27001_2022_m365.json @@ -117,6 +117,7 @@ "defender_malware_policy_notifications_internal_users_malware_enabled", "defender_safelinks_policy_enabled", "defender_zap_for_teams_enabled", + "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" @@ -715,7 +716,8 @@ "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", + "defender_identity_health_issues_no_open" ] }, { diff --git a/prowler/providers/m365/services/defenderidentity/__init__.py b/prowler/providers/m365/services/defenderidentity/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/defenderidentity/defenderidentity_client.py b/prowler/providers/m365/services/defenderidentity/defenderidentity_client.py new file mode 100644 index 0000000000..226e36f111 --- /dev/null +++ b/prowler/providers/m365/services/defenderidentity/defenderidentity_client.py @@ -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()) diff --git a/prowler/providers/m365/services/defenderidentity/defenderidentity_health_issues_no_open/__init__.py b/prowler/providers/m365/services/defenderidentity/defenderidentity_health_issues_no_open/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/defenderidentity/defenderidentity_health_issues_no_open/defenderidentity_health_issues_no_open.metadata.json b/prowler/providers/m365/services/defenderidentity/defenderidentity_health_issues_no_open/defenderidentity_health_issues_no_open.metadata.json new file mode 100644 index 0000000000..deaf9078f8 --- /dev/null +++ b/prowler/providers/m365/services/defenderidentity/defenderidentity_health_issues_no_open/defenderidentity_health_issues_no_open.metadata.json @@ -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." +} diff --git a/prowler/providers/m365/services/defenderidentity/defenderidentity_health_issues_no_open/defenderidentity_health_issues_no_open.py b/prowler/providers/m365/services/defenderidentity/defenderidentity_health_issues_no_open/defenderidentity_health_issues_no_open.py new file mode 100644 index 0000000000..32e1fe2201 --- /dev/null +++ b/prowler/providers/m365/services/defenderidentity/defenderidentity_health_issues_no_open/defenderidentity_health_issues_no_open.py @@ -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 diff --git a/prowler/providers/m365/services/defenderidentity/defenderidentity_service.py b/prowler/providers/m365/services/defenderidentity/defenderidentity_service.py new file mode 100644 index 0000000000..ccd5f50d10 --- /dev/null +++ b/prowler/providers/m365/services/defenderidentity/defenderidentity_service.py @@ -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] diff --git a/tests/providers/m365/services/defenderidentity/__init__.py b/tests/providers/m365/services/defenderidentity/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/defenderidentity/defenderidentity_health_issues_no_open/__init__.py b/tests/providers/m365/services/defenderidentity/defenderidentity_health_issues_no_open/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/m365/services/defenderidentity/defenderidentity_health_issues_no_open/defenderidentity_health_issues_no_open_test.py b/tests/providers/m365/services/defenderidentity/defenderidentity_health_issues_no_open/defenderidentity_health_issues_no_open_test.py new file mode 100644 index 0000000000..1602e9c821 --- /dev/null +++ b/tests/providers/m365/services/defenderidentity/defenderidentity_health_issues_no_open/defenderidentity_health_issues_no_open_test.py @@ -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