mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-29 23:04:23 +00:00
feat(azure): add iam_subscription_owners_between_2_and_3 check (#13145)
Co-authored-by: pedrooot <pedromarting3@gmail.com>
This commit is contained in:
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
|
||||
@@ -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",
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
Whitespace-only changes.
+38
@@ -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."
|
||||
}
|
||||
+63
@@ -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()
|
||||
+166
@@ -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
|
||||
Reference in new issue
Block a user