feat(azure): add iam_subscription_owners_between_2_and_3 check (#13145)

Co-authored-by: pedrooot <pedromarting3@gmail.com>
This commit is contained in:
Babafemi Bulugbeandpedrooot authored and GitHub committed 2026-10-29 11:11:42 +01:00
1 parent 8e83de67bc
commit 714d9283b0
9 files changed
+339 -2

No files matched your search

@@ -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
+3 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
@@ -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}"
)
@@ -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 <principal_id_or_upn> --role Owner --scope /subscriptions/<subscription_id>",
"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."
}
@@ -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
@@ -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()
@@ -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