From 8211657cf3f0648c952f891806f093b1a3f2b468 Mon Sep 17 00:00:00 2001 From: vpcleng Date: Wed, 28 Jan 2026 21:05:44 +1100 Subject: [PATCH] 1.1.4 and 5.1.4.5 --- .../entra/users/admin_license_footprint.py | 95 +++++++++++++++++++ engine/collectors/registry.py | 4 + .../v6.0.0/metadata.json | 18 ++-- 3 files changed, 108 insertions(+), 9 deletions(-) create mode 100644 engine/collectors/entra/users/admin_license_footprint.py diff --git a/engine/collectors/entra/users/admin_license_footprint.py b/engine/collectors/entra/users/admin_license_footprint.py new file mode 100644 index 00000000..db162745 --- /dev/null +++ b/engine/collectors/entra/users/admin_license_footprint.py @@ -0,0 +1,95 @@ +"""Admin license footprint collector. + +CIS Microsoft 365 Foundations Benchmark Controls: + v6.0.0: 1.1.4 + +Connection Method: Microsoft Graph API +Required Scopes: Directory.Read.All, User.Read.All +Graph Endpoints: /directoryRoles, /directoryRoles/{id}/members, /users/{id}, /subscribedSkus +""" + +from __future__ import annotations + +from typing import Any + +from collectors.base import BaseDataCollector +from collectors.graph_client import GraphClient + + +class AdminLicenseFootprintDataCollector(BaseDataCollector): + """Collects license assignments for administrative accounts. + + This collector identifies users with directory roles (admin accounts), + fetches their assigned licenses, and maps SKU IDs to SKU part numbers. + """ + + async def collect(self, client: GraphClient) -> dict[str, Any]: + # Fetch directory roles and members + roles = await client.get_directory_roles() + + admin_users: dict[str, dict[str, Any]] = {} + for role in roles: + role_id = role.get("id") + role_name = role.get("displayName") + if not role_id: + continue + + members = await client.get_role_members(role_id) + for member in members: + if member.get("@odata.type") != "#microsoft.graph.user": + continue + user_id = member.get("id") + if not user_id: + continue + + entry = admin_users.setdefault( + user_id, + { + "id": user_id, + "displayName": member.get("displayName"), + "userPrincipalName": member.get("userPrincipalName"), + "roles": [], + }, + ) + entry["roles"].append(role_name) + + # Map SKU IDs to readable names + sku_response = await client.get("/subscribedSkus") + sku_map = { + sku.get("skuId"): { + "skuPartNumber": sku.get("skuPartNumber"), + "prepaidUnits": sku.get("prepaidUnits"), + } + for sku in sku_response.get("value", []) + if sku.get("skuId") + } + + admin_license_details: list[dict[str, Any]] = [] + for user_id, info in admin_users.items(): + user_detail = await client.get( + f"/users/{user_id}", + params={ + "$select": "id,displayName,userPrincipalName,assignedLicenses,accountEnabled", + }, + ) + assigned = user_detail.get("assignedLicenses", []) + license_skus = [lic.get("skuId") for lic in assigned if lic.get("skuId")] + + admin_license_details.append( + { + "id": user_id, + "displayName": info.get("displayName") or user_detail.get("displayName"), + "userPrincipalName": info.get("userPrincipalName") + or user_detail.get("userPrincipalName"), + "accountEnabled": user_detail.get("accountEnabled"), + "roles": info.get("roles", []), + "assignedLicenses": assigned, + "assignedSkuIds": license_skus, + } + ) + + return { + "admin_users_count": len(admin_users), + "admin_users": admin_license_details, + "sku_map": sku_map, + } diff --git a/engine/collectors/registry.py b/engine/collectors/registry.py index 4959514e..67e31144 100644 --- a/engine/collectors/registry.py +++ b/engine/collectors/registry.py @@ -71,6 +71,9 @@ from collectors.entra.roles.cloud_only_admins import CloudOnlyAdminsDataCollector from collectors.entra.roles.directory_roles import DirectoryRolesDataCollector from collectors.entra.roles.privileged_roles import PrivilegedRolesDataCollector +from collectors.entra.users.admin_license_footprint import ( + AdminLicenseFootprintDataCollector, +) # Users from collectors.entra.users.users import UsersDataCollector @@ -188,6 +191,7 @@ "entra.roles.directory_roles": DirectoryRolesDataCollector, "entra.roles.privileged_roles": PrivilegedRolesDataCollector, # Users + "entra.users.admin_license_footprint": AdminLicenseFootprintDataCollector, "entra.users.users": UsersDataCollector, # Exchange - DNS "exchange.dns.dns_security_records": DnsSecurityRecordsDataCollector, diff --git a/engine/policies/cis/microsoft-365-foundations/v6.0.0/metadata.json b/engine/policies/cis/microsoft-365-foundations/v6.0.0/metadata.json index 6f611a82..8c9a6336 100644 --- a/engine/policies/cis/microsoft-365-foundations/v6.0.0/metadata.json +++ b/engine/policies/cis/microsoft-365-foundations/v6.0.0/metadata.json @@ -61,11 +61,11 @@ "level": "L1", "is_manual": false, "benchmark_audit_type": "Automated", - "automation_status": "not_started", - "data_collector_id": null, + "automation_status": "ready", + "data_collector_id": "entra.users.admin_license_footprint", "policy_file": null, - "requires_permissions": null, - "notes": "Need to check user license assignments" + "requires_permissions": ["Directory.Read.All", "User.Read.All"], + "notes": "Collect admin users and their assigned licenses" }, { "control_id": "1.2.1", @@ -91,7 +91,7 @@ "level": "L1", "is_manual": false, "benchmark_audit_type": "Automated", - "automation_status": "not_started", + "automation_status": "ready", "data_collector_id": "exchange.mailbox.mailboxes", "policy_file": null, "requires_permissions": ["Exchange.Manage"], @@ -811,11 +811,11 @@ "level": "L1", "is_manual": false, "benchmark_audit_type": "Automated", - "automation_status": "not_started", - "data_collector_id": null, + "automation_status": "ready", + "data_collector_id": "entra.devices.device_registration_policy", "policy_file": null, - "requires_permissions": null, - "notes": "Need LAPS configuration collector" + "requires_permissions": ["Policy.Read.DeviceConfiguration"], + "notes": "Uses deviceRegistrationPolicy localAdminPassword.isEnabled for LAPS state" }, { "control_id": "5.1.4.6",