diff --git a/prowler/changelog.d/azure-iam-subscription-owner-count.added.md b/prowler/changelog.d/azure-iam-subscription-owner-count.added.md new file mode 100644 index 0000000000..bf39bb545a --- /dev/null +++ b/prowler/changelog.d/azure-iam-subscription-owner-count.added.md @@ -0,0 +1 @@ +`iam_subscription_owners_between_2_and_3` check for Azure provider, verifying each subscription has between 2 and 3 principals with the Owner role, including owners inherited from management groups, mapped to CIS 5.0 and 6.0 diff --git a/prowler/compliance/azure/cis_5.0_azure.json b/prowler/compliance/azure/cis_5.0_azure.json index a19d0d3ddb..dd44659d43 100644 --- a/prowler/compliance/azure/cis_5.0_azure.json +++ b/prowler/compliance/azure/cis_5.0_azure.json @@ -1148,7 +1148,9 @@ { "Id": "5.27", "Description": "Ensure there are between 2 and 3 subscription owners", - "Checks": [], + "Checks": [ + "iam_subscription_owners_between_2_and_3" + ], "Attributes": [ { "Section": "5 Identity Services", diff --git a/prowler/compliance/azure/cis_6.0_azure.json b/prowler/compliance/azure/cis_6.0_azure.json index ec8464b243..07305f123b 100644 --- a/prowler/compliance/azure/cis_6.0_azure.json +++ b/prowler/compliance/azure/cis_6.0_azure.json @@ -595,7 +595,9 @@ { "Id": "5.7", "Description": "Ensure there are between 2 and 3 Subscription Owners", - "Checks": [], + "Checks": [ + "iam_subscription_owners_between_2_and_3" + ], "Attributes": [ { "Section": "5 Identity Services", diff --git a/prowler/providers/azure/services/iam/iam_service.py b/prowler/providers/azure/services/iam/iam_service.py index 6a9ff814e9..faec2a0e6c 100644 --- a/prowler/providers/azure/services/iam/iam_service.py +++ b/prowler/providers/azure/services/iam/iam_service.py @@ -12,6 +12,9 @@ class IAM(AzureService): def __init__(self, provider: AzureProvider): super().__init__(AuthorizationManagementClient, provider) self.roles, self.custom_roles = self._get_roles() + # Subscriptions whose role assignments could not be listed completely, + # so an empty or partial result there is not evidence of no assignments. + self.role_assignments_lookup_failed: set[str] = set() self.role_assignments = self._get_role_assignments() def _get_roles(self): @@ -82,6 +85,7 @@ class IAM(AzureService): } ) except Exception as error: + self.role_assignments_lookup_failed.add(subscription) logger.error( f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" ) diff --git a/prowler/providers/azure/services/iam/iam_subscription_owners_between_2_and_3/__init__.py b/prowler/providers/azure/services/iam/iam_subscription_owners_between_2_and_3/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/iam/iam_subscription_owners_between_2_and_3/iam_subscription_owners_between_2_and_3.metadata.json b/prowler/providers/azure/services/iam/iam_subscription_owners_between_2_and_3/iam_subscription_owners_between_2_and_3.metadata.json new file mode 100644 index 0000000000..4caff17962 --- /dev/null +++ b/prowler/providers/azure/services/iam/iam_subscription_owners_between_2_and_3/iam_subscription_owners_between_2_and_3.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "iam_subscription_owners_between_2_and_3", + "CheckTitle": "Subscription has between 2 and 3 principals assigned the Owner role", + "CheckType": [], + "ServiceName": "iam", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "microsoft.resources/subscriptions", + "ResourceGroup": "IAM", + "Description": "**Azure subscriptions** are evaluated for the number of principals holding the **Owner** role, whether assigned on the subscription or inherited from a management group. Users, groups, service principals and managed identities each count as one principal.", + "Risk": "**Too many owners** widen the set of identities that can change any resource, grant access or delete workloads, so one compromised account gains full control.\n\n**A single owner** is a single point of failure: if that account is lost or disabled, nobody can manage access or respond to incidents.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/role-based-access-control/best-practices", + "https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles/privileged#owner", + "https://learn.microsoft.com/en-us/azure/role-based-access-control/role-assignments-remove" + ], + "Remediation": { + "Code": { + "CLI": "az role assignment create --assignee --role Owner --scope /subscriptions/", + "NativeIaC": "", + "Other": "1. In the Azure portal, open Subscriptions and select the subscription\n2. Go to Access control (IAM) > Role assignments and filter by Role = Owner\n3. If more than 3 principals are listed, select the extra direct assignments (Scope: This resource) and remove them\n4. Remove extra inherited assignments where they are granted: click the link next to (Inherited) in the Scope column and remove the assignment at that management group or root scope\n5. If fewer than 2 are listed, click Add > Add role assignment and assign Owner to a second administrator or group", + "Terraform": "" + }, + "Recommendation": { + "Text": "Keep **2 to 3 standing owners** per subscription so access can always be recovered without concentrating full control:\n- Grant day-to-day work through narrower roles such as Contributor\n- Use **PIM** for just-in-time Owner elevation\n- Review Owner assignments and group membership regularly", + "Url": "https://hub.prowler.com/check/iam_subscription_owners_between_2_and_3" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "Counts unique principals with an active Owner assignment on the subscription or inherited from a management group or the root scope, and reports the direct and inherited split. PIM-eligible assignments are not counted." +} diff --git a/prowler/providers/azure/services/iam/iam_subscription_owners_between_2_and_3/iam_subscription_owners_between_2_and_3.py b/prowler/providers/azure/services/iam/iam_subscription_owners_between_2_and_3/iam_subscription_owners_between_2_and_3.py new file mode 100644 index 0000000000..528cffdf16 --- /dev/null +++ b/prowler/providers/azure/services/iam/iam_subscription_owners_between_2_and_3/iam_subscription_owners_between_2_and_3.py @@ -0,0 +1,63 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.config import OWNER_ROLE_ID +from prowler.providers.azure.services.iam.iam_client import iam_client + +MIN_OWNERS = 2 +MAX_OWNERS = 3 + + +class iam_subscription_owners_between_2_and_3(Check): + """Subscription has between 2 and 3 principals with the Owner role.""" + + def execute(self) -> list[Check_Report_Azure]: + """Count the principals that hold the Owner role on each subscription. + + Owners assigned on the subscription and owners inherited from a + management group or the root scope all count, once per principal. + + Returns: + One finding per subscription. MANUAL when the role assignments of + the subscription could not be read completely. + """ + findings = [] + for subscription_id, assignments in iam_client.role_assignments.items(): + subscription_name = iam_client.subscriptions.get( + subscription_id, subscription_id + ) + report = Check_Report_Azure(metadata=self.metadata(), resource={}) + report.subscription = subscription_id + report.resource_name = subscription_id + report.resource_id = f"/subscriptions/{subscription_id}" + + if subscription_id in iam_client.role_assignments_lookup_failed: + report.status = "MANUAL" + report.status_extended = f"Cannot evaluate the owner count of subscription {subscription_name} ({subscription_id}): its role assignments could not be read completely. Verify that Microsoft.Authorization/roleAssignments/read (included in Reader) is granted to the scanning identity." + findings.append(report) + continue + + subscription_scope = f"/subscriptions/{subscription_id}".casefold() + owner_assignments = [ + assignment + for assignment in assignments.values() + if assignment.role_id.casefold() == OWNER_ROLE_ID.casefold() + ] + owners = { + assignment.agent_id.casefold() for assignment in owner_assignments + } + direct_owners = { + assignment.agent_id.casefold() + for assignment in owner_assignments + if (assignment.scope or "").casefold() == subscription_scope + } + owner_count = len(owners) + inherited_count = len(owners - direct_owners) + breakdown = f"{len(direct_owners)} direct, {inherited_count} inherited" + + if MIN_OWNERS <= owner_count <= MAX_OWNERS: + report.status = "PASS" + report.status_extended = f"Subscription {subscription_name} ({subscription_id}) has an owner count of {owner_count} ({breakdown}), within the recommended {MIN_OWNERS} to {MAX_OWNERS}." + else: + report.status = "FAIL" + report.status_extended = f"Subscription {subscription_name} ({subscription_id}) has an owner count of {owner_count} ({breakdown}), outside the recommended {MIN_OWNERS} to {MAX_OWNERS}." + findings.append(report) + return findings diff --git a/tests/providers/azure/services/iam/azure_iam_service_test.py b/tests/providers/azure/services/iam/azure_iam_service_test.py index 27004ae512..895b7ec049 100644 --- a/tests/providers/azure/services/iam/azure_iam_service_test.py +++ b/tests/providers/azure/services/iam/azure_iam_service_test.py @@ -172,3 +172,64 @@ class Test_IAM_get_role_assignments: filter="atScope()" ) assert AZURE_SUBSCRIPTION_ID in result + + def test_get_role_assignments_marks_incomplete_read(self): + owner_assignment = MagicMock( + id="assignment-1", + scope=f"/subscriptions/{AZURE_SUBSCRIPTION_ID}", + principal_id="principal-1", + principal_type="User", + role_definition_id="/providers/Microsoft.Authorization/roleDefinitions/8e3af657-a8ff-443c-a75c-2fe8c4bcb635", + ) + owner_assignment.name = "assignment-1" + + def pages(): + yield owner_assignment + raise Exception("next page failed") + + mock_client = MagicMock() + mock_client.role_assignments.list_for_subscription.return_value = pages() + + with ( + patch( + "prowler.providers.azure.services.iam.iam_service.IAM._get_roles", + return_value=({}, {}), + ), + patch( + "prowler.providers.azure.services.iam.iam_service.IAM._get_role_assignments", + return_value={}, + ), + ): + iam = IAM(set_mocked_azure_provider()) + + iam.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + iam.resource_groups = None + + result = iam._get_role_assignments() + + assert list(result[AZURE_SUBSCRIPTION_ID]) == ["assignment-1"] + assert iam.role_assignments_lookup_failed == {AZURE_SUBSCRIPTION_ID} + + def test_get_role_assignments_complete_read_is_not_marked(self): + mock_client = MagicMock() + mock_client.role_assignments.list_for_subscription.return_value = [] + + with ( + patch( + "prowler.providers.azure.services.iam.iam_service.IAM._get_roles", + return_value=({}, {}), + ), + patch( + "prowler.providers.azure.services.iam.iam_service.IAM._get_role_assignments", + return_value={}, + ), + ): + iam = IAM(set_mocked_azure_provider()) + + iam.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + iam.resource_groups = None + + result = iam._get_role_assignments() + + assert result == {AZURE_SUBSCRIPTION_ID: {}} + assert iam.role_assignments_lookup_failed == set() diff --git a/tests/providers/azure/services/iam/iam_subscription_owners_between_2_and_3/iam_subscription_owners_between_2_and_3_test.py b/tests/providers/azure/services/iam/iam_subscription_owners_between_2_and_3/iam_subscription_owners_between_2_and_3_test.py new file mode 100644 index 0000000000..2de682984c --- /dev/null +++ b/tests/providers/azure/services/iam/iam_subscription_owners_between_2_and_3/iam_subscription_owners_between_2_and_3_test.py @@ -0,0 +1,166 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.config import OWNER_ROLE_ID +from prowler.providers.azure.services.iam.iam_service import RoleAssignment +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + set_mocked_azure_provider, +) + +CHECK = "iam_subscription_owners_between_2_and_3" +CHECK_PATH = f"prowler.providers.azure.services.iam.{CHECK}.{CHECK}" +SUBSCRIPTION_SCOPE = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}" +MANAGEMENT_GROUP_SCOPE = "/providers/Microsoft.Management/managementGroups/mg1" +READER_ROLE_ID = "acdd72a7-3385-48ef-bd42-f606fba81ae7" + + +def build_assignment( + role_id: str, scope: str = SUBSCRIPTION_SCOPE, agent_id: str = None +) -> RoleAssignment: + assignment_name = str(uuid4()) + return RoleAssignment( + id=f"{scope}/providers/Microsoft.Authorization/roleAssignments/{assignment_name}", + name=assignment_name, + scope=scope, + agent_id=agent_id or str(uuid4()), + agent_type="User", + role_id=role_id, + ) + + +def run_check(role_assignments, lookup_failed=()): + iam_client = mock.MagicMock() + iam_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + iam_client.role_assignments = role_assignments + iam_client.role_assignments_lookup_failed = set(lookup_failed) + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch(f"{CHECK_PATH}.iam_client", new=iam_client), + ): + from prowler.providers.azure.services.iam.iam_subscription_owners_between_2_and_3.iam_subscription_owners_between_2_and_3 import ( + iam_subscription_owners_between_2_and_3, + ) + + return iam_subscription_owners_between_2_and_3().execute() + + +def run_with(*assignments): + by_id = {assignment.id: assignment for assignment in assignments} + return run_check({AZURE_SUBSCRIPTION_ID: by_id}) + + +class Test_iam_subscription_owners_between_2_and_3: + def test_no_subscriptions(self): + assert run_check({}) == [] + + def test_role_assignments_lookup_failed_is_manual(self): + result = run_check( + {AZURE_SUBSCRIPTION_ID: {}}, lookup_failed={AZURE_SUBSCRIPTION_ID} + ) + assert len(result) == 1 + assert result[0].status == "MANUAL" + assert ( + result[0].status_extended + == f"Cannot evaluate the owner count of subscription {AZURE_SUBSCRIPTION_DISPLAY}: its role assignments could not be read completely. Verify that Microsoft.Authorization/roleAssignments/read (included in Reader) is granted to the scanning identity." + ) + assert result[0].resource_id == SUBSCRIPTION_SCOPE + + def test_partial_read_with_two_owners_is_manual(self): + owners = [build_assignment(OWNER_ROLE_ID) for _ in range(2)] + result = run_check( + {AZURE_SUBSCRIPTION_ID: {owner.id: owner for owner in owners}}, + lookup_failed={AZURE_SUBSCRIPTION_ID}, + ) + assert len(result) == 1 + assert result[0].status == "MANUAL" + + def test_no_role_assignments_fail(self): + result = run_check({AZURE_SUBSCRIPTION_ID: {}}) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Subscription {AZURE_SUBSCRIPTION_DISPLAY} has an owner count of 0 (0 direct, 0 inherited), outside the recommended 2 to 3." + ) + + def test_two_direct_owners_pass(self): + result = run_with( + build_assignment(OWNER_ROLE_ID), + build_assignment(OWNER_ROLE_ID), + build_assignment(READER_ROLE_ID), + ) + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Subscription {AZURE_SUBSCRIPTION_DISPLAY} has an owner count of 2 (2 direct, 0 inherited), within the recommended 2 to 3." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_id == SUBSCRIPTION_SCOPE + assert result[0].resource_name == AZURE_SUBSCRIPTION_ID + assert result[0].location == "global" + + def test_three_owners_pass(self): + result = run_with(*[build_assignment(OWNER_ROLE_ID) for _ in range(3)]) + assert result[0].status == "PASS" + assert "owner count of 3 (3 direct, 0 inherited)" in result[0].status_extended + + def test_one_owner_fail(self): + result = run_with( + build_assignment(OWNER_ROLE_ID), build_assignment(READER_ROLE_ID) + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Subscription {AZURE_SUBSCRIPTION_DISPLAY} has an owner count of 1 (1 direct, 0 inherited), outside the recommended 2 to 3." + ) + + def test_four_owners_fail(self): + result = run_with(*[build_assignment(OWNER_ROLE_ID) for _ in range(4)]) + assert result[0].status == "FAIL" + assert "owner count of 4" in result[0].status_extended + + def test_inherited_owner_counted(self): + result = run_with( + build_assignment(OWNER_ROLE_ID), + build_assignment(OWNER_ROLE_ID, scope=MANAGEMENT_GROUP_SCOPE), + ) + assert result[0].status == "PASS" + assert "owner count of 2 (1 direct, 1 inherited)" in result[0].status_extended + + def test_owner_at_both_scopes_counted_once(self): + agent_id = str(uuid4()) + result = run_with( + build_assignment(OWNER_ROLE_ID, agent_id=agent_id), + build_assignment( + OWNER_ROLE_ID, scope=MANAGEMENT_GROUP_SCOPE, agent_id=agent_id + ), + ) + assert result[0].status == "FAIL" + assert "owner count of 1 (1 direct, 0 inherited)" in result[0].status_extended + + def test_owner_at_both_scopes_with_different_casing_counted_once(self): + agent_id = str(uuid4()) + result = run_with( + build_assignment(OWNER_ROLE_ID, agent_id=agent_id), + build_assignment( + OWNER_ROLE_ID, scope=MANAGEMENT_GROUP_SCOPE, agent_id=agent_id.upper() + ), + ) + assert result[0].status == "FAIL" + assert "owner count of 1 (1 direct, 0 inherited)" in result[0].status_extended + + def test_owner_role_id_matched_case_insensitively(self): + result = run_with( + build_assignment(OWNER_ROLE_ID.upper()), + build_assignment(OWNER_ROLE_ID.upper()), + ) + assert result[0].status == "PASS" + assert "owner count of 2 (2 direct, 0 inherited)" in result[0].status_extended