diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index d9e512672c..1d40a147c7 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to the **Prowler SDK** are documented in this file. +## [5.32.0] (Prowler UNRELEASED) + +### 🚀 Added + +- `entra_conditional_access_policy_explicitly_targets_azure_devops` check for M365 provider, verifying at least one enabled Conditional Access policy explicitly includes the Azure DevOps cloud application instead of relying on a broad "All cloud apps" policy [(#11182)](https://github.com/prowler-cloud/prowler/pull/11182) + +--- + ## [5.31.1] (Prowler UNRELEASED) ### 🐞 Fixed diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_explicitly_targets_azure_devops/__init__.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_explicitly_targets_azure_devops/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_explicitly_targets_azure_devops/entra_conditional_access_policy_explicitly_targets_azure_devops.metadata.json b/prowler/providers/m365/services/entra/entra_conditional_access_policy_explicitly_targets_azure_devops/entra_conditional_access_policy_explicitly_targets_azure_devops.metadata.json new file mode 100644 index 0000000000..c352854003 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_explicitly_targets_azure_devops/entra_conditional_access_policy_explicitly_targets_azure_devops.metadata.json @@ -0,0 +1,43 @@ +{ + "Provider": "m365", + "CheckID": "entra_conditional_access_policy_explicitly_targets_azure_devops", + "CheckTitle": "Conditional Access Policy explicitly targets Azure DevOps", + "CheckType": [], + "ServiceName": "entra", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "NotDefined", + "ResourceGroup": "IAM", + "Description": "Microsoft Entra **Conditional Access** is verified to have at least one **enabled** policy that explicitly includes the **Azure DevOps** cloud application. Policies targeting **All** cloud apps do not satisfy this check because the goal is to verify that Azure DevOps has been deliberately considered.", + "Risk": "Without an explicit Conditional Access policy for Azure DevOps, organizations may rely on broad policies that do not account for Azure DevOps-specific access patterns such as CLI, IDE plug-ins, PAT-based workflows, source code access, build pipelines, secrets, and service connections.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccesspolicy?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/graph/api/resources/conditionalaccessapplications?view=graph-rest-1.0", + "https://learn.microsoft.com/en-us/azure/devops/organizations/accounts/manage-conditional-access", + "https://learn.microsoft.com/en-us/troubleshoot/entra/entra-id/governance/verify-first-party-apps-sign-in" + ], + "Remediation": { + "Code": { + "CLI": "", + "NativeIaC": "", + "Other": "1. Navigate to the Microsoft Entra admin center (https://entra.microsoft.com).\n2. Expand **Protection** > **Conditional Access** and select **Policies**.\n3. Create or edit a policy for Azure DevOps.\n4. Under **Target resources**, select **Include** > **Select apps** and choose **Azure DevOps**.\n5. Configure the required grant or session controls for your organization.\n6. Set the policy to **Report-only** until validated, then enable it.", + "Terraform": "" + }, + "Recommendation": { + "Text": "Create and enable a Conditional Access policy that explicitly includes the Azure DevOps cloud application, then configure the appropriate access controls for your organization.", + "Url": "https://hub.prowler.com/check/entra_conditional_access_policy_explicitly_targets_azure_devops" + } + }, + "Categories": [ + "identity-access", + "trust-boundaries", + "e3" + ], + "DependsOn": [], + "RelatedTo": [ + "entra_conditional_access_policy_all_apps_all_users" + ], + "Notes": "Azure DevOps Services uses appId 499b84ac-1321-427f-aa17-267ca6975798." +} diff --git a/prowler/providers/m365/services/entra/entra_conditional_access_policy_explicitly_targets_azure_devops/entra_conditional_access_policy_explicitly_targets_azure_devops.py b/prowler/providers/m365/services/entra/entra_conditional_access_policy_explicitly_targets_azure_devops/entra_conditional_access_policy_explicitly_targets_azure_devops.py new file mode 100644 index 0000000000..f1e874fc91 --- /dev/null +++ b/prowler/providers/m365/services/entra/entra_conditional_access_policy_explicitly_targets_azure_devops/entra_conditional_access_policy_explicitly_targets_azure_devops.py @@ -0,0 +1,57 @@ +"""Check if a Conditional Access policy explicitly targets Azure DevOps.""" + +from prowler.lib.check.models import Check, CheckReportM365 +from prowler.providers.m365.services.entra.entra_client import entra_client +from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicyState, +) + +AZURE_DEVOPS_APP_ID = "499b84ac-1321-427f-aa17-267ca6975798" + + +class entra_conditional_access_policy_explicitly_targets_azure_devops(Check): + """Check that an enabled Conditional Access policy explicitly targets Azure DevOps.""" + + def execute(self) -> list[CheckReportM365]: + """Execute the check for explicit Azure DevOps targeting. + + Returns: + A list of reports containing the result of the check. + """ + findings = [] + report = CheckReportM365( + metadata=self.metadata(), + resource={}, + resource_name="Conditional Access Policies", + resource_id="conditionalAccessPolicies", + ) + report.status = "FAIL" + report.status_extended = ( + "No enabled Conditional Access Policy explicitly targets Azure DevOps." + ) + + for policy in entra_client.conditional_access_policies.values(): + if policy.state != ConditionalAccessPolicyState.ENABLED: + continue + + if not policy.conditions.application_conditions: + continue + + if ( + AZURE_DEVOPS_APP_ID + not in policy.conditions.application_conditions.included_applications + ): + continue + + report = CheckReportM365( + metadata=self.metadata(), + resource=policy, + resource_name=policy.display_name, + resource_id=policy.id, + ) + report.status = "PASS" + report.status_extended = f"Conditional Access Policy {policy.display_name} explicitly targets Azure DevOps." + break + + findings.append(report) + return findings diff --git a/tests/providers/m365/services/entra/entra_conditional_access_policy_explicitly_targets_azure_devops/entra_conditional_access_policy_explicitly_targets_azure_devops_test.py b/tests/providers/m365/services/entra/entra_conditional_access_policy_explicitly_targets_azure_devops/entra_conditional_access_policy_explicitly_targets_azure_devops_test.py new file mode 100644 index 0000000000..c120034e1e --- /dev/null +++ b/tests/providers/m365/services/entra/entra_conditional_access_policy_explicitly_targets_azure_devops/entra_conditional_access_policy_explicitly_targets_azure_devops_test.py @@ -0,0 +1,191 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.m365.services.entra.entra_service import ( + ApplicationsConditions, + ConditionalAccessGrantControl, + ConditionalAccessPolicyState, + Conditions, + GrantControlOperator, + GrantControls, + PersistentBrowser, + SessionControls, + SignInFrequency, + SignInFrequencyInterval, + UsersConditions, +) +from tests.providers.m365.m365_fixtures import DOMAIN, set_mocked_m365_provider + +CHECK_MODULE_PATH = "prowler.providers.m365.services.entra.entra_conditional_access_policy_explicitly_targets_azure_devops.entra_conditional_access_policy_explicitly_targets_azure_devops" + +AZURE_DEVOPS_APP_ID = "499b84ac-1321-427f-aa17-267ca6975798" + + +def _make_session_controls(): + """Return default session controls for test policies.""" + return SessionControls( + persistent_browser=PersistentBrowser(is_enabled=False, mode="always"), + sign_in_frequency=SignInFrequency( + is_enabled=False, + frequency=None, + type=None, + interval=SignInFrequencyInterval.EVERY_TIME, + ), + ) + + +def _make_conditions(included_applications=None): + """Return Conditions with the given included applications.""" + return Conditions( + application_conditions=ApplicationsConditions( + included_applications=included_applications or ["All"], + excluded_applications=[], + included_user_actions=[], + ), + user_conditions=UsersConditions( + included_groups=[], + excluded_groups=[], + included_users=["All"], + excluded_users=[], + included_roles=[], + excluded_roles=[], + ), + client_app_types=[], + user_risk_levels=[], + ) + + +def _make_grant_controls(): + """Return default grant controls for test policies.""" + return GrantControls( + built_in_controls=[ConditionalAccessGrantControl.MFA], + operator=GrantControlOperator.AND, + authentication_strength=None, + ) + + +def _make_policy(state, included_applications=None, display_name="Azure DevOps Policy"): + """Return a ConditionalAccessPolicy for tests.""" + from prowler.providers.m365.services.entra.entra_service import ( + ConditionalAccessPolicy, + ) + + return ConditionalAccessPolicy( + id=str(uuid4()), + display_name=display_name, + conditions=_make_conditions(included_applications=included_applications), + grant_controls=_make_grant_controls(), + session_controls=_make_session_controls(), + state=state, + ) + + +class Test_entra_conditional_access_policy_explicitly_targets_azure_devops: + def _run_check(self, policies): + 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( + f"{CHECK_MODULE_PATH}.entra_client", + new=entra_client, + ), + ): + from prowler.providers.m365.services.entra.entra_conditional_access_policy_explicitly_targets_azure_devops.entra_conditional_access_policy_explicitly_targets_azure_devops import ( + entra_conditional_access_policy_explicitly_targets_azure_devops, + ) + + entra_client.conditional_access_policies = policies + + check = entra_conditional_access_policy_explicitly_targets_azure_devops() + return check.execute() + + def test_no_conditional_access_policies(self): + result = self._run_check({}) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No enabled Conditional Access Policy explicitly targets Azure DevOps." + ) + assert result[0].resource == {} + assert result[0].resource_name == "Conditional Access Policies" + assert result[0].resource_id == "conditionalAccessPolicies" + assert result[0].location == "global" + + def test_enabled_policy_targets_azure_devops(self): + policy = _make_policy( + ConditionalAccessPolicyState.ENABLED, + included_applications=[AZURE_DEVOPS_APP_ID], + ) + result = self._run_check({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Conditional Access Policy {policy.display_name} explicitly targets Azure DevOps." + ) + assert result[0].resource_name == policy.display_name + assert result[0].resource_id == policy.id + + def test_enabled_policy_targets_all_apps_only(self): + policy = _make_policy( + ConditionalAccessPolicyState.ENABLED, included_applications=["All"] + ) + result = self._run_check({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No enabled Conditional Access Policy explicitly targets Azure DevOps." + ) + + def test_disabled_policy_targets_azure_devops(self): + policy = _make_policy( + ConditionalAccessPolicyState.DISABLED, + included_applications=[AZURE_DEVOPS_APP_ID], + ) + result = self._run_check({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No enabled Conditional Access Policy explicitly targets Azure DevOps." + ) + + def test_report_only_policy_targets_azure_devops(self): + policy = _make_policy( + ConditionalAccessPolicyState.ENABLED_FOR_REPORTING, + included_applications=[AZURE_DEVOPS_APP_ID], + ) + result = self._run_check({policy.id: policy}) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == "No enabled Conditional Access Policy explicitly targets Azure DevOps." + ) + + def test_multiple_policies_one_targets_azure_devops(self): + non_matching = _make_policy( + ConditionalAccessPolicyState.ENABLED, + included_applications=["All"], + display_name="All Apps Policy", + ) + matching = _make_policy( + ConditionalAccessPolicyState.ENABLED, + included_applications=["some-other-app", AZURE_DEVOPS_APP_ID], + display_name="Dedicated Azure DevOps Policy", + ) + result = self._run_check({non_matching.id: non_matching, matching.id: matching}) + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == matching.id + assert ( + result[0].status_extended + == f"Conditional Access Policy {matching.display_name} explicitly targets Azure DevOps." + )