From bc3fd794574b2c375c96e73ba24a44ae1fa07331 Mon Sep 17 00:00:00 2001 From: Hugo Pereira Brito <101209179+HugoPBrito@users.noreply.github.com> Date: Tue, 14 Apr 2026 12:01:47 +0100 Subject: [PATCH] feat(intune): add device compliance policy marks noncompliant check (#10599) --- prowler/CHANGELOG.md | 1 + prowler/compliance/m365/cis_6.0_m365.json | 4 +- .../__init__.py | 0 ...ces_not_compliant_by_default.metadata.json | 36 ++++ ...signed_devices_not_compliant_by_default.py | 52 ++++++ .../m365/services/intune/intune_service.py | 13 +- ...d_devices_not_compliant_by_default_test.py | 163 ++++++++++++++++++ .../services/intune/intune_service_test.py | 54 ++++++ 8 files changed, 319 insertions(+), 4 deletions(-) create mode 100644 prowler/providers/m365/services/intune/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default/__init__.py create mode 100644 prowler/providers/m365/services/intune/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default.metadata.json create mode 100644 prowler/providers/m365/services/intune/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default.py create mode 100644 tests/providers/m365/services/intune/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index d5e5feb8e5..6675bb5e7f 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 +- `intune_device_compliance_policy_unassigned_devices_not_compliant_by_default` check for M365 provider [(#10599)](https://github.com/prowler-cloud/prowler/pull/10599) - `entra_conditional_access_policy_all_apps_all_users` check for M365 provider [(#10619)](https://github.com/prowler-cloud/prowler/pull/10619) - `bedrock_full_access_policy_attached` check for AWS provider [(#10577)](https://github.com/prowler-cloud/prowler/pull/10577) - `iam_role_access_not_stale_to_bedrock` and `iam_user_access_not_stale_to_bedrock` checks for AWS provider [(#10536)](https://github.com/prowler-cloud/prowler/pull/10536) diff --git a/prowler/compliance/m365/cis_6.0_m365.json b/prowler/compliance/m365/cis_6.0_m365.json index f13c323df4..d0dcfb2e6d 100644 --- a/prowler/compliance/m365/cis_6.0_m365.json +++ b/prowler/compliance/m365/cis_6.0_m365.json @@ -873,7 +873,9 @@ { "Id": "4.1", "Description": "Compliance policies are sets of rules and conditions that are used to evaluate the configuration of managed devices. These policies can help secure organizational data and resources from devices that don't meet those configuration requirements. The recommended state is Mark devices with no compliance policy assigned as Not compliant.", - "Checks": [], + "Checks": [ + "intune_device_compliance_policy_unassigned_devices_not_compliant_by_default" + ], "Attributes": [ { "Section": "4 Microsoft Intune admin center", diff --git a/prowler/providers/m365/services/intune/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default/__init__.py b/prowler/providers/m365/services/intune/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/intune/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default.metadata.json b/prowler/providers/m365/services/intune/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default.metadata.json new file mode 100644 index 0000000000..0d7fac5ba5 --- /dev/null +++ b/prowler/providers/m365/services/intune/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default.metadata.json @@ -0,0 +1,36 @@ +{ + "Provider": "m365", + "CheckID": "intune_device_compliance_policy_unassigned_devices_not_compliant_by_default", + "CheckTitle": "Built-in Device Compliance Policy marks devices without an assigned compliance policy as Not compliant by default", + "CheckType": [], + "ServiceName": "intune", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "NotDefined", + "ResourceGroup": "security", + "Description": "Intune has a built-in Device Compliance Policy that governs how devices without an explicit compliance policy are treated. When the default behavior marks those devices as Compliant, unmanaged devices can be treated as compliant and gain access to corporate resources. This check verifies the default is set to Not compliant (secureByDefault = true).", + "Risk": "If the built-in policy marks devices without a compliance policy as Compliant, those devices can bypass Conditional Access policies requiring device compliance, granting unauthorized access to corporate resources from unmanaged or non-compliant endpoints.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/graph/api/resources/intune-deviceconfig-devicemanagementsettings?view=graph-rest-1.0" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Sign in to the Microsoft Intune admin center (intune.microsoft.com)\n2. Go to Devices > Compliance\n3. Select Compliance policy settings\n4. Set 'Mark devices with no compliance policy assigned as' to 'Not compliant'\n5. Save the settings", + "Terraform": "" + }, + "Recommendation": { + "Text": "Set the built-in Device Compliance Policy default so devices with no compliance policy assigned are marked as Not compliant.", + "Url": "https://hub.prowler.com/check/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default" + } + }, + "Categories": [ + "trust-boundaries" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "The check evaluates the secureByDefault property from the deviceManagement/settings Microsoft Graph endpoint." +} diff --git a/prowler/providers/m365/services/intune/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default.py b/prowler/providers/m365/services/intune/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default.py new file mode 100644 index 0000000000..a7f377baea --- /dev/null +++ b/prowler/providers/m365/services/intune/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default.py @@ -0,0 +1,52 @@ +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.intune.intune_client import intune_client + + +class intune_device_compliance_policy_unassigned_devices_not_compliant_by_default( + Check +): + """Ensure the built-in Device Compliance Policy marks unassigned devices as Not compliant by default.""" + + def execute(self) -> list[CheckReportM365]: + findings = [] + + report = CheckReportM365( + metadata=self.metadata(), + resource=intune_client.settings or {}, + resource_name="Intune Device Compliance Settings", + resource_id="deviceManagement/settings", + ) + + verification_error = getattr(intune_client, "verification_error", None) + settings = getattr(intune_client, "settings", None) + secure_by_default = getattr(settings, "secure_by_default", None) + + if verification_error: + report.status = "MANUAL" + report.status_extended = ( + "Intune built-in Device Compliance Policy could not be verified. " + f"{verification_error}" + ) + elif settings is None or secure_by_default is None: + report.status = "MANUAL" + report.status_extended = ( + "Intune built-in Device Compliance Policy could not be verified " + "because Microsoft Graph did not return the secure-by-default " + "compliance setting." + ) + elif secure_by_default is True: + report.status = "PASS" + report.status_extended = ( + "Intune built-in Device Compliance Policy marks devices " + "with no compliance policy assigned as Not compliant." + ) + else: + report.status = "FAIL" + report.status_extended = ( + "Intune built-in Device Compliance Policy marks devices " + "with no compliance policy assigned as Compliant. " + "Change the default to Not compliant in Intune settings." + ) + + findings.append(report) + return findings diff --git a/prowler/providers/m365/services/intune/intune_service.py b/prowler/providers/m365/services/intune/intune_service.py index 4b62f64e99..2f6c0febae 100644 --- a/prowler/providers/m365/services/intune/intune_service.py +++ b/prowler/providers/m365/services/intune/intune_service.py @@ -125,6 +125,15 @@ class Intune(M365Service): request_configuration=request_configuration ) settings = getattr(device_management, "settings", None) + secure_by_default = getattr(settings, "secure_by_default", None) + + # Some tenants/API responses omit nested settings when $select is used. + # Retry without query parameters before concluding the value is unavailable. + if settings is None or secure_by_default is None: + device_management = await self.client.device_management.get() + settings = getattr(device_management, "settings", None) + secure_by_default = getattr(settings, "secure_by_default", None) + if settings is None: return ( IntuneSettings(secure_by_default=None), @@ -132,9 +141,7 @@ class Intune(M365Service): ) return ( - IntuneSettings( - secure_by_default=getattr(settings, "secure_by_default", None) - ), + IntuneSettings(secure_by_default=secure_by_default), None, ) except Exception as error: diff --git a/tests/providers/m365/services/intune/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default_test.py b/tests/providers/m365/services/intune/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default_test.py new file mode 100644 index 0000000000..3dc51076eb --- /dev/null +++ b/tests/providers/m365/services/intune/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default/intune_device_compliance_policy_unassigned_devices_not_compliant_by_default_test.py @@ -0,0 +1,163 @@ +from unittest import mock + +from prowler.providers.m365.services.intune.intune_service import IntuneSettings +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.intune.intune_device_compliance_policy_unassigned_devices_not_compliant_by_default.intune_device_compliance_policy_unassigned_devices_not_compliant_by_default" + + +class Test_intune_device_compliance_policy_unassigned_devices_not_compliant_by_default: + def test_secure_by_default_true(self): + intune_client = mock.MagicMock() + intune_client.audited_tenant = "audited_tenant" + intune_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.intune_client", new=intune_client), + ): + from prowler.providers.m365.services.intune.intune_device_compliance_policy_unassigned_devices_not_compliant_by_default.intune_device_compliance_policy_unassigned_devices_not_compliant_by_default import ( + intune_device_compliance_policy_unassigned_devices_not_compliant_by_default, + ) + + intune_client.settings = IntuneSettings(secure_by_default=True) + intune_client.verification_error = None + + result = ( + intune_device_compliance_policy_unassigned_devices_not_compliant_by_default().execute() + ) + + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].status_extended == ( + "Intune built-in Device Compliance Policy marks devices " + "with no compliance policy assigned as Not compliant." + ) + + def test_secure_by_default_false(self): + intune_client = mock.MagicMock() + intune_client.audited_tenant = "audited_tenant" + intune_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.intune_client", new=intune_client), + ): + from prowler.providers.m365.services.intune.intune_device_compliance_policy_unassigned_devices_not_compliant_by_default.intune_device_compliance_policy_unassigned_devices_not_compliant_by_default import ( + intune_device_compliance_policy_unassigned_devices_not_compliant_by_default, + ) + + intune_client.settings = IntuneSettings(secure_by_default=False) + intune_client.verification_error = None + + result = ( + intune_device_compliance_policy_unassigned_devices_not_compliant_by_default().execute() + ) + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Intune built-in Device Compliance Policy marks devices " + "with no compliance policy assigned as Compliant. " + "Change the default to Not compliant in Intune settings." + ) + + def test_secure_by_default_none(self): + intune_client = mock.MagicMock() + intune_client.audited_tenant = "audited_tenant" + intune_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.intune_client", new=intune_client), + ): + from prowler.providers.m365.services.intune.intune_device_compliance_policy_unassigned_devices_not_compliant_by_default.intune_device_compliance_policy_unassigned_devices_not_compliant_by_default import ( + intune_device_compliance_policy_unassigned_devices_not_compliant_by_default, + ) + + intune_client.settings = IntuneSettings(secure_by_default=None) + intune_client.verification_error = None + + result = ( + intune_device_compliance_policy_unassigned_devices_not_compliant_by_default().execute() + ) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].status_extended == ( + "Intune built-in Device Compliance Policy could not be verified " + "because Microsoft Graph did not return the secure-by-default " + "compliance setting." + ) + + def test_settings_is_none(self): + intune_client = mock.MagicMock() + intune_client.audited_tenant = "audited_tenant" + intune_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.intune_client", new=intune_client), + ): + from prowler.providers.m365.services.intune.intune_device_compliance_policy_unassigned_devices_not_compliant_by_default.intune_device_compliance_policy_unassigned_devices_not_compliant_by_default import ( + intune_device_compliance_policy_unassigned_devices_not_compliant_by_default, + ) + + intune_client.settings = None + intune_client.verification_error = None + + result = ( + intune_device_compliance_policy_unassigned_devices_not_compliant_by_default().execute() + ) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].status_extended == ( + "Intune built-in Device Compliance Policy could not be verified " + "because Microsoft Graph did not return the secure-by-default " + "compliance setting." + ) + + def test_verification_error_returns_manual(self): + intune_client = mock.MagicMock() + intune_client.audited_tenant = "audited_tenant" + intune_client.audited_domain = DOMAIN + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_m365_provider(), + ), + mock.patch(f"{CHECK_MODULE_PATH}.intune_client", new=intune_client), + ): + from prowler.providers.m365.services.intune.intune_device_compliance_policy_unassigned_devices_not_compliant_by_default.intune_device_compliance_policy_unassigned_devices_not_compliant_by_default import ( + intune_device_compliance_policy_unassigned_devices_not_compliant_by_default, + ) + + intune_client.settings = None + intune_client.verification_error = ( + "Could not read Microsoft Intune device management settings." + ) + + result = ( + intune_device_compliance_policy_unassigned_devices_not_compliant_by_default().execute() + ) + + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert result[0].status_extended == ( + "Intune built-in Device Compliance Policy could not be verified. " + "Could not read Microsoft Intune device management settings." + ) diff --git a/tests/providers/m365/services/intune/intune_service_test.py b/tests/providers/m365/services/intune/intune_service_test.py index 3e6762391f..1e273d04e9 100644 --- a/tests/providers/m365/services/intune/intune_service_test.py +++ b/tests/providers/m365/services/intune/intune_service_test.py @@ -349,6 +349,60 @@ def test_intune_get_settings_null_settings(): assert settings.secure_by_default is None +def test_intune_get_settings_retries_without_select_when_settings_missing(): + """Test _get_settings retries without $select when settings are omitted.""" + intune = Intune.__new__(Intune) + + selected_response = SimpleNamespace(settings=None) + full_response = SimpleNamespace(settings=SimpleNamespace(secure_by_default=True)) + + mock_client = mock.MagicMock() + mock_client.device_management.get = AsyncMock( + side_effect=[selected_response, full_response] + ) + + intune.client = mock_client + + loop = asyncio.new_event_loop() + try: + settings, error = loop.run_until_complete(intune._get_settings()) + finally: + loop.close() + + assert error is None + assert settings is not None + assert settings.secure_by_default is True + assert mock_client.device_management.get.await_count == 2 + + +def test_intune_get_settings_retries_without_select_when_value_missing(): + """Test _get_settings retries without $select when secure_by_default is omitted.""" + intune = Intune.__new__(Intune) + + selected_response = SimpleNamespace( + settings=SimpleNamespace(secure_by_default=None) + ) + full_response = SimpleNamespace(settings=SimpleNamespace(secure_by_default=False)) + + mock_client = mock.MagicMock() + mock_client.device_management.get = AsyncMock( + side_effect=[selected_response, full_response] + ) + + intune.client = mock_client + + loop = asyncio.new_event_loop() + try: + settings, error = loop.run_until_complete(intune._get_settings()) + finally: + loop.close() + + assert error is None + assert settings is not None + assert settings.secure_by_default is False + assert mock_client.device_management.get.await_count == 2 + + def test_intune_get_settings_exception(): """Test _get_settings handles exceptions gracefully.""" intune = Intune.__new__(Intune)