From 8b814b76436f939db4bb001fcad7cb6ed7d5ba09 Mon Sep 17 00:00:00 2001 From: Rajesh Nadiminti Date: Wed, 14 Oct 2026 02:03:25 -0500 Subject: [PATCH] feat(azure): add aiservices service with network, local auth and CMK checks (#12925) Co-authored-by: pedrooot --- .../azure-aiservices-service.added.md | 1 + .../azure/services/aiservices/__init__.py | 0 .../__init__.py | 0 ...s_account_encrypted_with_cmk.metadata.json | 38 ++++ .../aiservices_account_encrypted_with_cmk.py | 53 +++++ .../__init__.py | 0 ..._account_local_auth_disabled.metadata.json | 38 ++++ .../aiservices_account_local_auth_disabled.py | 34 +++ .../__init__.py | 0 ...blic_network_access_disabled.metadata.json | 38 ++++ ..._account_public_network_access_disabled.py | 36 ++++ .../services/aiservices/aiservices_client.py | 4 + .../services/aiservices/aiservices_service.py | 97 +++++++++ pyproject.toml | 1 + ...ervices_account_encrypted_with_cmk_test.py | 123 +++++++++++ ...rvices_account_local_auth_disabled_test.py | 74 +++++++ ...unt_public_network_access_disabled_test.py | 78 +++++++ .../aiservices/aiservices_service_test.py | 200 ++++++++++++++++++ uv.lock | 16 ++ 19 files changed, 831 insertions(+) create mode 100644 prowler/changelog.d/azure-aiservices-service.added.md create mode 100644 prowler/providers/azure/services/aiservices/__init__.py create mode 100644 prowler/providers/azure/services/aiservices/aiservices_account_encrypted_with_cmk/__init__.py create mode 100644 prowler/providers/azure/services/aiservices/aiservices_account_encrypted_with_cmk/aiservices_account_encrypted_with_cmk.metadata.json create mode 100644 prowler/providers/azure/services/aiservices/aiservices_account_encrypted_with_cmk/aiservices_account_encrypted_with_cmk.py create mode 100644 prowler/providers/azure/services/aiservices/aiservices_account_local_auth_disabled/__init__.py create mode 100644 prowler/providers/azure/services/aiservices/aiservices_account_local_auth_disabled/aiservices_account_local_auth_disabled.metadata.json create mode 100644 prowler/providers/azure/services/aiservices/aiservices_account_local_auth_disabled/aiservices_account_local_auth_disabled.py create mode 100644 prowler/providers/azure/services/aiservices/aiservices_account_public_network_access_disabled/__init__.py create mode 100644 prowler/providers/azure/services/aiservices/aiservices_account_public_network_access_disabled/aiservices_account_public_network_access_disabled.metadata.json create mode 100644 prowler/providers/azure/services/aiservices/aiservices_account_public_network_access_disabled/aiservices_account_public_network_access_disabled.py create mode 100644 prowler/providers/azure/services/aiservices/aiservices_client.py create mode 100644 prowler/providers/azure/services/aiservices/aiservices_service.py create mode 100644 tests/providers/azure/services/aiservices/aiservices_account_encrypted_with_cmk/aiservices_account_encrypted_with_cmk_test.py create mode 100644 tests/providers/azure/services/aiservices/aiservices_account_local_auth_disabled/aiservices_account_local_auth_disabled_test.py create mode 100644 tests/providers/azure/services/aiservices/aiservices_account_public_network_access_disabled/aiservices_account_public_network_access_disabled_test.py create mode 100644 tests/providers/azure/services/aiservices/aiservices_service_test.py diff --git a/prowler/changelog.d/azure-aiservices-service.added.md b/prowler/changelog.d/azure-aiservices-service.added.md new file mode 100644 index 0000000000..3ea7280267 --- /dev/null +++ b/prowler/changelog.d/azure-aiservices-service.added.md @@ -0,0 +1 @@ +Azure AI Services service for Azure provider, including the `aiservices_account_public_network_access_disabled`, `aiservices_account_local_auth_disabled`, and `aiservices_account_encrypted_with_cmk` checks diff --git a/prowler/providers/azure/services/aiservices/__init__.py b/prowler/providers/azure/services/aiservices/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_encrypted_with_cmk/__init__.py b/prowler/providers/azure/services/aiservices/aiservices_account_encrypted_with_cmk/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_encrypted_with_cmk/aiservices_account_encrypted_with_cmk.metadata.json b/prowler/providers/azure/services/aiservices/aiservices_account_encrypted_with_cmk/aiservices_account_encrypted_with_cmk.metadata.json new file mode 100644 index 0000000000..d95ea2d2e1 --- /dev/null +++ b/prowler/providers/azure/services/aiservices/aiservices_account_encrypted_with_cmk/aiservices_account_encrypted_with_cmk.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "aiservices_account_encrypted_with_cmk", + "CheckTitle": "AI services account is encrypted with a customer-managed key", + "CheckType": [], + "ServiceName": "aiservices", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "microsoft.cognitiveservices/accounts", + "ResourceGroup": "ai_ml", + "Description": "**Azure AI services accounts** (including **Azure OpenAI** and **Azure AI Foundry**) encrypt stored data such as fine-tuning files, custom models, and batch inputs. Setting the encryption **key source** to `Microsoft.KeyVault` uses a **customer-managed key** held in Azure Key Vault instead of a Microsoft-managed key.", + "Risk": "With Microsoft-managed keys the organization cannot:\n- **Revoke** access to stored AI data by disabling its own key\n- **Rotate** keys on its own schedule or audit key usage in Key Vault\n- **Meet** regulatory requirements that mandate customer-controlled encryption keys", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/ai-services/encryption/cognitive-services-encryption-keys-portal", + "https://learn.microsoft.com/en-us/azure/foundry-classic/openai/encrypt-data-at-rest" + ], + "Remediation": { + "Code": { + "CLI": "az cognitiveservices account update --name --resource-group --encryption '{\"keySource\":\"Microsoft.KeyVault\",\"keyVaultProperties\":{\"keyVaultUri\":\"\",\"keyName\":\"\",\"keyVersion\":\"\"}}'", + "NativeIaC": "```bicep\n// Encrypt an Azure AI services account with a customer-managed key\nresource account 'Microsoft.CognitiveServices/accounts@2024-10-01' = {\n name: ''\n location: ''\n kind: 'OpenAI'\n sku: { name: 'S0' }\n identity: { type: 'SystemAssigned' }\n properties: {\n customSubDomainName: ''\n encryption: {\n keySource: 'Microsoft.KeyVault' // CRITICAL: use a customer-managed key\n keyVaultProperties: {\n keyName: ''\n keyVaultUri: ''\n }\n }\n }\n}\n```", + "Other": "1. Create a key in an Azure Key Vault with soft delete and purge protection enabled\n2. Enable a managed identity on the AI services account and grant it get, wrapKey, and unwrapKey on the key\n3. In the Azure portal, open the AI services account and go to Resource Management > Encryption\n4. Select Customer Managed Keys, choose the key, and click Save", + "Terraform": "```hcl\nresource \"azurerm_cognitive_account_customer_managed_key\" \"\" {\n cognitive_account_id = azurerm_cognitive_account..id\n key_vault_key_id = azurerm_key_vault_key..id # CRITICAL: use a customer-managed key\n}\n```" + }, + "Recommendation": { + "Text": "Encrypt AI services accounts that store sensitive data with a **customer-managed key** in Azure Key Vault. Enable **soft delete** and **purge protection** on the vault, grant the account's **managed identity** only the key permissions it needs, and rotate the key on a defined schedule.", + "Url": "https://hub.prowler.com/check/aiservices_account_encrypted_with_cmk" + } + }, + "Categories": [ + "encryption", + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_encrypted_with_cmk/aiservices_account_encrypted_with_cmk.py b/prowler/providers/azure/services/aiservices/aiservices_account_encrypted_with_cmk/aiservices_account_encrypted_with_cmk.py new file mode 100644 index 0000000000..a451d3993f --- /dev/null +++ b/prowler/providers/azure/services/aiservices/aiservices_account_encrypted_with_cmk/aiservices_account_encrypted_with_cmk.py @@ -0,0 +1,53 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.aiservices.aiservices_client import ( + aiservices_client, +) +from prowler.providers.azure.services.aiservices.aiservices_service import Account + +KEY_VAULT_KEY_SOURCE = "Microsoft.KeyVault" + + +def uses_customer_managed_key(account: Account) -> bool: + """Decide whether the account is encrypted with a customer-managed key. + + Args: + account: AI services account. + + Returns: + True when the account counts as encrypted with a customer-managed key. + A Key Vault key source passes even when no key name is reported. + """ + return (account.encryption_key_source or "").lower() == KEY_VAULT_KEY_SOURCE.lower() + + +class aiservices_account_encrypted_with_cmk(Check): + """AI services account is encrypted with a customer-managed key.""" + + def execute(self) -> list[Check_Report_Azure]: + """Evaluate encryption key source on every AI services account. + + Returns: + One finding per account. + """ + findings = [] + for subscription_id, accounts in aiservices_client.accounts.items(): + subscription_name = aiservices_client.subscriptions.get( + subscription_id, subscription_id + ) + for account in accounts.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=account) + report.subscription = subscription_id + prefix = f"AI services account {account.name} (kind {account.kind}) from subscription {subscription_name} ({subscription_id})" + if uses_customer_managed_key(account): + report.status = "PASS" + if account.encryption_key_name: + report.status_extended = f"{prefix} is encrypted with customer-managed key {account.encryption_key_name}." + else: + report.status_extended = f"{prefix} is encrypted with a customer-managed key from Azure Key Vault." + else: + report.status = "FAIL" + report.status_extended = ( + f"{prefix} is encrypted with a Microsoft-managed key." + ) + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_local_auth_disabled/__init__.py b/prowler/providers/azure/services/aiservices/aiservices_account_local_auth_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_local_auth_disabled/aiservices_account_local_auth_disabled.metadata.json b/prowler/providers/azure/services/aiservices/aiservices_account_local_auth_disabled/aiservices_account_local_auth_disabled.metadata.json new file mode 100644 index 0000000000..8868b353eb --- /dev/null +++ b/prowler/providers/azure/services/aiservices/aiservices_account_local_auth_disabled/aiservices_account_local_auth_disabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "aiservices_account_local_auth_disabled", + "CheckTitle": "AI services account has local (API key) authentication disabled", + "CheckType": [], + "ServiceName": "aiservices", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "microsoft.cognitiveservices/accounts", + "ResourceGroup": "ai_ml", + "Description": "**Azure AI services accounts** (including **Azure OpenAI** and **Azure AI Foundry**) accept static **API keys** unless `disableLocalAuth` is `true`. With local authentication disabled, every request must use a **Microsoft Entra ID** token.", + "Risk": "Static API keys never expire and carry no caller identity:\n- **Confidentiality**: a leaked key grants full data-plane access to models and data\n- **Accountability**: requests cannot be traced to a user or workload\n- **Control**: Conditional Access, RBAC scoping, and token lifetimes do not apply to keys", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/ai-services/disable-local-auth", + "https://learn.microsoft.com/en-us/azure/ai-services/authentication" + ], + "Remediation": { + "Code": { + "CLI": "az resource update --ids --set properties.disableLocalAuth=true", + "NativeIaC": "```bicep\n// Require Microsoft Entra ID for an Azure AI services account\nresource account 'Microsoft.CognitiveServices/accounts@2024-10-01' = {\n name: ''\n location: ''\n kind: 'OpenAI'\n sku: { name: 'S0' }\n properties: {\n customSubDomainName: ''\n disableLocalAuth: true // CRITICAL: rejects API keys, requires Entra ID tokens\n }\n}\n```", + "Other": "1. Move every client to Microsoft Entra ID authentication (managed identity or service principal with the Cognitive Services User role)\n2. Run the Azure CLI command above, or deploy the Bicep or Terraform example\n3. Confirm that requests sent with an API key now return 401", + "Terraform": "```hcl\nresource \"azurerm_cognitive_account\" \"\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n kind = \"OpenAI\"\n sku_name = \"S0\"\n custom_subdomain_name = \"\"\n\n local_auth_enabled = false # CRITICAL: rejects API keys, requires Entra ID tokens\n}\n```" + }, + "Recommendation": { + "Text": "Set `disableLocalAuth` to `true` and authenticate clients with **managed identities** or service principals through **Microsoft Entra ID**. Grant least-privilege roles such as **Cognitive Services OpenAI User** instead of sharing keys.", + "Url": "https://hub.prowler.com/check/aiservices_account_local_auth_disabled" + } + }, + "Categories": [ + "identity-access", + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_local_auth_disabled/aiservices_account_local_auth_disabled.py b/prowler/providers/azure/services/aiservices/aiservices_account_local_auth_disabled/aiservices_account_local_auth_disabled.py new file mode 100644 index 0000000000..982d3b663a --- /dev/null +++ b/prowler/providers/azure/services/aiservices/aiservices_account_local_auth_disabled/aiservices_account_local_auth_disabled.py @@ -0,0 +1,34 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.aiservices.aiservices_client import ( + aiservices_client, +) + + +class aiservices_account_local_auth_disabled(Check): + """AI services account has local (API key) authentication disabled.""" + + def execute(self) -> list[Check_Report_Azure]: + """Evaluate local authentication on every AI services account. + + Returns: + One finding per account. + """ + findings = [] + for subscription_id, accounts in aiservices_client.accounts.items(): + subscription_name = aiservices_client.subscriptions.get( + subscription_id, subscription_id + ) + for account in accounts.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=account) + report.subscription = subscription_id + prefix = f"AI services account {account.name} (kind {account.kind}) from subscription {subscription_name} ({subscription_id})" + if account.disable_local_auth: + report.status = "PASS" + report.status_extended = f"{prefix} has local (API key) authentication disabled and requires Microsoft Entra ID." + else: + report.status = "FAIL" + report.status_extended = ( + f"{prefix} allows local (API key) authentication." + ) + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_public_network_access_disabled/__init__.py b/prowler/providers/azure/services/aiservices/aiservices_account_public_network_access_disabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_public_network_access_disabled/aiservices_account_public_network_access_disabled.metadata.json b/prowler/providers/azure/services/aiservices/aiservices_account_public_network_access_disabled/aiservices_account_public_network_access_disabled.metadata.json new file mode 100644 index 0000000000..dee9d17d2b --- /dev/null +++ b/prowler/providers/azure/services/aiservices/aiservices_account_public_network_access_disabled/aiservices_account_public_network_access_disabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "azure", + "CheckID": "aiservices_account_public_network_access_disabled", + "CheckTitle": "AI services account has public network access disabled", + "CheckType": [], + "ServiceName": "aiservices", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "microsoft.cognitiveservices/accounts", + "ResourceGroup": "ai_ml", + "Description": "**Azure AI services accounts** (including **Azure OpenAI** and **Azure AI Foundry**) can block their data-plane endpoint from the public internet by setting **public network access** to `Disabled`, so clients connect only through **private endpoints**.", + "Risk": "An internet-reachable AI endpoint exposes:\n- **Confidentiality**: prompts, completions, and grounding data to anyone holding a leaked key or token\n- **Integrity**: unauthorized model calls and deployment changes\n- **Availability**: quota exhaustion and cost abuse from scanning and automated traffic", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://learn.microsoft.com/en-us/azure/ai-services/cognitive-services-virtual-networks", + "https://learn.microsoft.com/en-us/security/benchmark/azure/baselines/cognitive-services-security-baseline" + ], + "Remediation": { + "Code": { + "CLI": "az resource update --ids --set properties.publicNetworkAccess=Disabled", + "NativeIaC": "```bicep\n// Disable public network access for an Azure AI services account\nresource account 'Microsoft.CognitiveServices/accounts@2024-10-01' = {\n name: ''\n location: ''\n kind: 'OpenAI'\n sku: { name: 'S0' }\n properties: {\n customSubDomainName: ''\n publicNetworkAccess: 'Disabled' // CRITICAL: blocks access from the public internet\n }\n}\n```", + "Other": "1. In the Azure portal, open the Azure AI services or Azure OpenAI resource\n2. Go to Resource Management > Networking\n3. Under Firewalls and virtual networks, select Disabled\n4. Click Save", + "Terraform": "```hcl\nresource \"azurerm_cognitive_account\" \"\" {\n name = \"\"\n location = \"\"\n resource_group_name = \"\"\n kind = \"OpenAI\"\n sku_name = \"S0\"\n custom_subdomain_name = \"\"\n\n public_network_access_enabled = false # CRITICAL: blocks access from the public internet\n}\n```" + }, + "Recommendation": { + "Text": "Set **public network access** to `Disabled` and reach the account through **Private Link**. If public access is temporarily required, restrict it to specific IP ranges or virtual networks and require Microsoft Entra ID authentication.", + "Url": "https://hub.prowler.com/check/aiservices_account_public_network_access_disabled" + } + }, + "Categories": [ + "internet-exposed", + "gen-ai" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/aiservices/aiservices_account_public_network_access_disabled/aiservices_account_public_network_access_disabled.py b/prowler/providers/azure/services/aiservices/aiservices_account_public_network_access_disabled/aiservices_account_public_network_access_disabled.py new file mode 100644 index 0000000000..b7268f1342 --- /dev/null +++ b/prowler/providers/azure/services/aiservices/aiservices_account_public_network_access_disabled/aiservices_account_public_network_access_disabled.py @@ -0,0 +1,36 @@ +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.aiservices.aiservices_client import ( + aiservices_client, +) + + +class aiservices_account_public_network_access_disabled(Check): + """AI services account has public network access disabled.""" + + def execute(self) -> list[Check_Report_Azure]: + """Evaluate public network access on every AI services account. + + Returns: + One finding per account. + """ + findings = [] + for subscription_id, accounts in aiservices_client.accounts.items(): + subscription_name = aiservices_client.subscriptions.get( + subscription_id, subscription_id + ) + for account in accounts.values(): + report = Check_Report_Azure(metadata=self.metadata(), resource=account) + report.subscription = subscription_id + prefix = f"AI services account {account.name} (kind {account.kind}) from subscription {subscription_name} ({subscription_id})" + if account.public_network_access: + report.status = "FAIL" + report.status_extended = ( + f"{prefix} has public network access enabled." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"{prefix} has public network access disabled." + ) + findings.append(report) + return findings diff --git a/prowler/providers/azure/services/aiservices/aiservices_client.py b/prowler/providers/azure/services/aiservices/aiservices_client.py new file mode 100644 index 0000000000..af0e34e68f --- /dev/null +++ b/prowler/providers/azure/services/aiservices/aiservices_client.py @@ -0,0 +1,4 @@ +from prowler.providers.azure.services.aiservices.aiservices_service import AIServices +from prowler.providers.common.provider import Provider + +aiservices_client = AIServices(Provider.get_global_provider()) diff --git a/prowler/providers/azure/services/aiservices/aiservices_service.py b/prowler/providers/azure/services/aiservices/aiservices_service.py new file mode 100644 index 0000000000..d4e8e71d01 --- /dev/null +++ b/prowler/providers/azure/services/aiservices/aiservices_service.py @@ -0,0 +1,97 @@ +from typing import Optional + +from azure.mgmt.cognitiveservices import CognitiveServicesManagementClient +from pydantic.v1 import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.azure.azure_provider import AzureProvider +from prowler.providers.azure.lib.service.service import AzureService + +MICROSOFT_MANAGED_KEY_SOURCE = "Microsoft.CognitiveServices" + + +class AIServices(AzureService): + """Azure AI services (Microsoft.CognitiveServices/accounts). + + Covers every account kind, including Azure OpenAI (`OpenAI`) and + Azure AI Foundry (`AIServices`). + """ + + def __init__(self, provider: AzureProvider): + super().__init__(CognitiveServicesManagementClient, provider) + self.accounts = self._get_accounts() + + def _get_accounts(self) -> dict[str, dict[str, "Account"]]: + """Get Azure AI services accounts for every audited subscription. + + Returns: + Accounts keyed by subscription ID, then by account resource ID. + """ + logger.info("AIServices - Getting accounts...") + accounts = {} + for subscription, client in self.clients.items(): + accounts[subscription] = {} + try: + sdk_accounts = self.list_with_rg_scope( + subscription, + client.accounts.list, + client.accounts.list_by_resource_group, + ) + except Exception as error: + logger.error( + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + continue + for sdk_account in sdk_accounts: + try: + account = self._to_account(sdk_account) + accounts[subscription][account.id] = account + except Exception as error: + logger.error( + f"Subscription ID: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return accounts + + @staticmethod + def _to_account(sdk_account) -> "Account": + """Map an SDK account to the Prowler model. + + Unset properties follow Azure defaults: public network access on, + local (key) authentication on, and Microsoft-managed encryption. + + Args: + sdk_account: `azure.mgmt.cognitiveservices.models.Account`. + + Returns: + The mapped `Account`. + """ + properties = sdk_account.properties + encryption = getattr(properties, "encryption", None) + key_vault_properties = getattr(encryption, "key_vault_properties", None) + return Account( + id=sdk_account.id, + name=sdk_account.name, + location=sdk_account.location, + kind=sdk_account.kind or "", + public_network_access=( + getattr(properties, "public_network_access", None) != "Disabled" + ), + disable_local_auth=bool(getattr(properties, "disable_local_auth", False)), + encryption_key_source=( + getattr(encryption, "key_source", None) or MICROSOFT_MANAGED_KEY_SOURCE + ), + encryption_key_name=getattr(key_vault_properties, "key_name", None), + ) + + +class Account(BaseModel): + """Azure AI services account.""" + + id: str + name: str + location: str + kind: str + public_network_access: bool + disable_local_auth: bool + encryption_key_source: str + encryption_key_name: Optional[str] = None diff --git a/pyproject.toml b/pyproject.toml index a2173c3ac7..3481da25bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ dependencies = [ "azure-keyvault-keys==4.10.0", "azure-mgmt-applicationinsights==4.1.0", "azure-mgmt-authorization==4.0.0", + "azure-mgmt-cognitiveservices==14.1.0", "azure-mgmt-compute==34.0.0", "azure-mgmt-containerregistry==12.0.0", "azure-mgmt-containerservice==34.1.0", diff --git a/tests/providers/azure/services/aiservices/aiservices_account_encrypted_with_cmk/aiservices_account_encrypted_with_cmk_test.py b/tests/providers/azure/services/aiservices/aiservices_account_encrypted_with_cmk/aiservices_account_encrypted_with_cmk_test.py new file mode 100644 index 0000000000..f4118656e4 --- /dev/null +++ b/tests/providers/azure/services/aiservices/aiservices_account_encrypted_with_cmk/aiservices_account_encrypted_with_cmk_test.py @@ -0,0 +1,123 @@ +from unittest import mock + +from prowler.providers.azure.services.aiservices.aiservices_service import Account +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + RESOURCE_GROUP, + set_mocked_azure_provider, +) + +CHECK = "aiservices_account_encrypted_with_cmk" +CHECK_PATH = f"prowler.providers.azure.services.aiservices.{CHECK}.{CHECK}" +ACCOUNT_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/{RESOURCE_GROUP}/providers/Microsoft.CognitiveServices/accounts/openai1" + + +def build_account(key_source: str, key_name=None) -> Account: + return Account( + id=ACCOUNT_ID, + name="openai1", + location="eastus", + kind="OpenAI", + public_network_access=False, + disable_local_auth=True, + encryption_key_source=key_source, + encryption_key_name=key_name, + ) + + +def run_check(accounts): + aiservices_client = mock.MagicMock() + aiservices_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + aiservices_client.accounts = accounts + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch(f"{CHECK_PATH}.aiservices_client", new=aiservices_client), + ): + from prowler.providers.azure.services.aiservices.aiservices_account_encrypted_with_cmk.aiservices_account_encrypted_with_cmk import ( + aiservices_account_encrypted_with_cmk, + ) + + return aiservices_account_encrypted_with_cmk().execute() + + +class Test_aiservices_account_encrypted_with_cmk: + def test_no_resources(self): + result = run_check({}) + assert len(result) == 0 + + def test_microsoft_managed_key(self): + result = run_check( + { + AZURE_SUBSCRIPTION_ID: { + ACCOUNT_ID: build_account("Microsoft.CognitiveServices") + } + } + ) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"AI services account openai1 (kind OpenAI) from subscription {AZURE_SUBSCRIPTION_DISPLAY} is encrypted with a Microsoft-managed key." + ) + assert result[0].resource_id == ACCOUNT_ID + assert result[0].resource_name == "openai1" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + + def test_customer_managed_key(self): + result = run_check( + { + AZURE_SUBSCRIPTION_ID: { + ACCOUNT_ID: build_account("Microsoft.KeyVault", "cmk1") + } + } + ) + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"AI services account openai1 (kind OpenAI) from subscription {AZURE_SUBSCRIPTION_DISPLAY} is encrypted with customer-managed key cmk1." + ) + assert result[0].resource_id == ACCOUNT_ID + assert result[0].resource_name == "openai1" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + + def test_key_vault_source_without_key_name(self): + result = run_check( + { + AZURE_SUBSCRIPTION_ID: { + ACCOUNT_ID: build_account("Microsoft.KeyVault", None) + } + } + ) + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"AI services account openai1 (kind OpenAI) from subscription {AZURE_SUBSCRIPTION_DISPLAY} is encrypted with a customer-managed key from Azure Key Vault." + ) + assert result[0].resource_id == ACCOUNT_ID + assert result[0].resource_name == "openai1" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + + def test_key_vault_source_case_insensitive(self): + result = run_check( + { + AZURE_SUBSCRIPTION_ID: { + ACCOUNT_ID: build_account("Microsoft.Keyvault", "cmk1") + } + } + ) + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"AI services account openai1 (kind OpenAI) from subscription {AZURE_SUBSCRIPTION_DISPLAY} is encrypted with customer-managed key cmk1." + ) + assert result[0].resource_id == ACCOUNT_ID + assert result[0].resource_name == "openai1" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID diff --git a/tests/providers/azure/services/aiservices/aiservices_account_local_auth_disabled/aiservices_account_local_auth_disabled_test.py b/tests/providers/azure/services/aiservices/aiservices_account_local_auth_disabled/aiservices_account_local_auth_disabled_test.py new file mode 100644 index 0000000000..413b171109 --- /dev/null +++ b/tests/providers/azure/services/aiservices/aiservices_account_local_auth_disabled/aiservices_account_local_auth_disabled_test.py @@ -0,0 +1,74 @@ +from unittest import mock + +from prowler.providers.azure.services.aiservices.aiservices_service import Account +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + RESOURCE_GROUP, + set_mocked_azure_provider, +) + +CHECK = "aiservices_account_local_auth_disabled" +CHECK_PATH = f"prowler.providers.azure.services.aiservices.{CHECK}.{CHECK}" +ACCOUNT_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/{RESOURCE_GROUP}/providers/Microsoft.CognitiveServices/accounts/foundry1" + + +def build_account(disable_local_auth: bool) -> Account: + return Account( + id=ACCOUNT_ID, + name="foundry1", + location="swedencentral", + kind="AIServices", + public_network_access=False, + disable_local_auth=disable_local_auth, + encryption_key_source="Microsoft.CognitiveServices", + ) + + +def run_check(accounts): + aiservices_client = mock.MagicMock() + aiservices_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + aiservices_client.accounts = accounts + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch(f"{CHECK_PATH}.aiservices_client", new=aiservices_client), + ): + from prowler.providers.azure.services.aiservices.aiservices_account_local_auth_disabled.aiservices_account_local_auth_disabled import ( + aiservices_account_local_auth_disabled, + ) + + return aiservices_account_local_auth_disabled().execute() + + +class Test_aiservices_account_local_auth_disabled: + def test_no_resources(self): + result = run_check({}) + assert len(result) == 0 + + def test_local_auth_enabled(self): + result = run_check({AZURE_SUBSCRIPTION_ID: {ACCOUNT_ID: build_account(False)}}) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"AI services account foundry1 (kind AIServices) from subscription {AZURE_SUBSCRIPTION_DISPLAY} allows local (API key) authentication." + ) + assert result[0].resource_id == ACCOUNT_ID + assert result[0].resource_name == "foundry1" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + + def test_local_auth_disabled(self): + result = run_check({AZURE_SUBSCRIPTION_ID: {ACCOUNT_ID: build_account(True)}}) + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"AI services account foundry1 (kind AIServices) from subscription {AZURE_SUBSCRIPTION_DISPLAY} has local (API key) authentication disabled and requires Microsoft Entra ID." + ) + assert result[0].resource_id == ACCOUNT_ID + assert result[0].resource_name == "foundry1" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID diff --git a/tests/providers/azure/services/aiservices/aiservices_account_public_network_access_disabled/aiservices_account_public_network_access_disabled_test.py b/tests/providers/azure/services/aiservices/aiservices_account_public_network_access_disabled/aiservices_account_public_network_access_disabled_test.py new file mode 100644 index 0000000000..199375e9f0 --- /dev/null +++ b/tests/providers/azure/services/aiservices/aiservices_account_public_network_access_disabled/aiservices_account_public_network_access_disabled_test.py @@ -0,0 +1,78 @@ +from unittest import mock + +from prowler.providers.azure.services.aiservices.aiservices_service import Account +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_DISPLAY, + AZURE_SUBSCRIPTION_ID, + AZURE_SUBSCRIPTION_NAME, + RESOURCE_GROUP, + set_mocked_azure_provider, +) + +CHECK = "aiservices_account_public_network_access_disabled" +CHECK_PATH = f"prowler.providers.azure.services.aiservices.{CHECK}.{CHECK}" +ACCOUNT_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/{RESOURCE_GROUP}/providers/Microsoft.CognitiveServices/accounts/openai1" + + +def build_account(public_network_access: bool) -> Account: + return Account( + id=ACCOUNT_ID, + name="openai1", + location="eastus", + kind="OpenAI", + public_network_access=public_network_access, + disable_local_auth=True, + encryption_key_source="Microsoft.CognitiveServices", + ) + + +def run_check(accounts): + aiservices_client = mock.MagicMock() + aiservices_client.subscriptions = {AZURE_SUBSCRIPTION_ID: AZURE_SUBSCRIPTION_NAME} + aiservices_client.accounts = accounts + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), + mock.patch(f"{CHECK_PATH}.aiservices_client", new=aiservices_client), + ): + from prowler.providers.azure.services.aiservices.aiservices_account_public_network_access_disabled.aiservices_account_public_network_access_disabled import ( + aiservices_account_public_network_access_disabled, + ) + + return aiservices_account_public_network_access_disabled().execute() + + +class Test_aiservices_account_public_network_access_disabled: + def test_no_resources(self): + result = run_check({}) + assert len(result) == 0 + + def test_no_accounts_in_subscription(self): + assert run_check({AZURE_SUBSCRIPTION_ID: {}}) == [] + + def test_public_network_access_enabled(self): + result = run_check({AZURE_SUBSCRIPTION_ID: {ACCOUNT_ID: build_account(True)}}) + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"AI services account openai1 (kind OpenAI) from subscription {AZURE_SUBSCRIPTION_DISPLAY} has public network access enabled." + ) + assert result[0].resource_id == ACCOUNT_ID + assert result[0].resource_name == "openai1" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].location == "eastus" + + def test_public_network_access_disabled(self): + result = run_check({AZURE_SUBSCRIPTION_ID: {ACCOUNT_ID: build_account(False)}}) + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"AI services account openai1 (kind OpenAI) from subscription {AZURE_SUBSCRIPTION_DISPLAY} has public network access disabled." + ) + assert result[0].resource_id == ACCOUNT_ID + assert result[0].resource_name == "openai1" + assert result[0].subscription == AZURE_SUBSCRIPTION_ID diff --git a/tests/providers/azure/services/aiservices/aiservices_service_test.py b/tests/providers/azure/services/aiservices/aiservices_service_test.py new file mode 100644 index 0000000000..2e872ec297 --- /dev/null +++ b/tests/providers/azure/services/aiservices/aiservices_service_test.py @@ -0,0 +1,200 @@ +from unittest.mock import MagicMock, patch + +from prowler.providers.azure.services.aiservices.aiservices_service import ( + Account, + AIServices, +) +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + RESOURCE_GROUP, + RESOURCE_GROUP_LIST, + set_mocked_azure_provider, +) + +ACCOUNT_ID = f"/subscriptions/{AZURE_SUBSCRIPTION_ID}/resourceGroups/{RESOURCE_GROUP}/providers/Microsoft.CognitiveServices/accounts/openai1" +SERVICE_PATH = "prowler.providers.azure.services.aiservices.aiservices_service" + + +def mock_get_accounts(_): + return { + AZURE_SUBSCRIPTION_ID: { + ACCOUNT_ID: Account( + id=ACCOUNT_ID, + name="openai1", + location="eastus", + kind="OpenAI", + public_network_access=True, + disable_local_auth=False, + encryption_key_source="Microsoft.CognitiveServices", + encryption_key_name=None, + ) + } + } + + +def build_sdk_account( + public_network_access="Disabled", + disable_local_auth=True, + encryption=None, + properties_none=False, +): + account = MagicMock() + account.id = ACCOUNT_ID + account.name = "openai1" + account.location = "eastus" + account.kind = "OpenAI" + if properties_none: + account.properties = None + return account + account.properties.public_network_access = public_network_access + account.properties.disable_local_auth = disable_local_auth + account.properties.encryption = encryption + return account + + +def build_service(mock_client, resource_groups=None): + with patch(f"{SERVICE_PATH}.AIServices._get_accounts", return_value={}): + aiservices = AIServices(set_mocked_azure_provider()) + aiservices.clients = {AZURE_SUBSCRIPTION_ID: mock_client} + aiservices.resource_groups = resource_groups + return aiservices + + +@patch(f"{SERVICE_PATH}.AIServices._get_accounts", new=mock_get_accounts) +class Test_AIServices_Service: + def test_get_client(self): + aiservices = AIServices(set_mocked_azure_provider()) + assert ( + aiservices.clients[AZURE_SUBSCRIPTION_ID].__class__.__name__ + == "CognitiveServicesManagementClient" + ) + + def test_accounts_populated(self): + aiservices = AIServices(set_mocked_azure_provider()) + account = aiservices.accounts[AZURE_SUBSCRIPTION_ID][ACCOUNT_ID] + assert account.name == "openai1" + assert account.kind == "OpenAI" + + +class Test_AIServices_get_accounts: + def test_no_resource_groups_lists_subscription(self): + mock_client = MagicMock() + mock_client.accounts.list.return_value = [build_sdk_account()] + aiservices = build_service(mock_client) + + result = aiservices._get_accounts() + + mock_client.accounts.list.assert_called_once() + mock_client.accounts.list_by_resource_group.assert_not_called() + account = result[AZURE_SUBSCRIPTION_ID][ACCOUNT_ID] + assert account.public_network_access is False + assert account.disable_local_auth is True + assert account.encryption_key_source == "Microsoft.CognitiveServices" + assert account.encryption_key_name is None + + def test_single_resource_group(self): + mock_client = MagicMock() + mock_client.accounts.list_by_resource_group.return_value = [build_sdk_account()] + aiservices = build_service( + mock_client, {AZURE_SUBSCRIPTION_ID: [RESOURCE_GROUP]} + ) + + result = aiservices._get_accounts() + + mock_client.accounts.list_by_resource_group.assert_called_once_with( + resource_group_name=RESOURCE_GROUP + ) + mock_client.accounts.list.assert_not_called() + assert ACCOUNT_ID in result[AZURE_SUBSCRIPTION_ID] + + def test_empty_resource_group_list_skips(self): + mock_client = MagicMock() + aiservices = build_service(mock_client, {AZURE_SUBSCRIPTION_ID: []}) + + result = aiservices._get_accounts() + + mock_client.accounts.list.assert_not_called() + mock_client.accounts.list_by_resource_group.assert_not_called() + assert result[AZURE_SUBSCRIPTION_ID] == {} + + def test_multiple_resource_groups(self): + mock_client = MagicMock() + mock_client.accounts.list_by_resource_group.return_value = [] + aiservices = build_service( + mock_client, {AZURE_SUBSCRIPTION_ID: RESOURCE_GROUP_LIST} + ) + + aiservices._get_accounts() + + assert mock_client.accounts.list_by_resource_group.call_count == 2 + + def test_public_network_access_none_treated_as_enabled(self): + mock_client = MagicMock() + mock_client.accounts.list.return_value = [ + build_sdk_account(public_network_access=None) + ] + aiservices = build_service(mock_client) + + account = aiservices._get_accounts()[AZURE_SUBSCRIPTION_ID][ACCOUNT_ID] + + assert account.public_network_access is True + + def test_disable_local_auth_none_treated_as_enabled_local_auth(self): + mock_client = MagicMock() + mock_client.accounts.list.return_value = [ + build_sdk_account(disable_local_auth=None) + ] + aiservices = build_service(mock_client) + + account = aiservices._get_accounts()[AZURE_SUBSCRIPTION_ID][ACCOUNT_ID] + + assert account.disable_local_auth is False + + def test_customer_managed_key_mapped(self): + encryption = MagicMock() + encryption.key_source = "Microsoft.KeyVault" + encryption.key_vault_properties.key_name = "cmk1" + mock_client = MagicMock() + mock_client.accounts.list.return_value = [ + build_sdk_account(encryption=encryption) + ] + aiservices = build_service(mock_client) + + account = aiservices._get_accounts()[AZURE_SUBSCRIPTION_ID][ACCOUNT_ID] + + assert account.encryption_key_source == "Microsoft.KeyVault" + assert account.encryption_key_name == "cmk1" + + def test_properties_none_uses_safe_defaults(self): + mock_client = MagicMock() + mock_client.accounts.list.return_value = [ + build_sdk_account(properties_none=True) + ] + aiservices = build_service(mock_client) + + account = aiservices._get_accounts()[AZURE_SUBSCRIPTION_ID][ACCOUNT_ID] + + assert account.public_network_access is True + assert account.disable_local_auth is False + assert account.encryption_key_source == "Microsoft.CognitiveServices" + + def test_list_failure_leaves_subscription_empty(self): + mock_client = MagicMock() + mock_client.accounts.list.side_effect = Exception("boom") + aiservices = build_service(mock_client) + + result = aiservices._get_accounts() + + assert result[AZURE_SUBSCRIPTION_ID] == {} + + def test_one_bad_account_does_not_drop_others(self): + bad = build_sdk_account() + bad.id = None # fails Pydantic validation: id must be str + good = build_sdk_account() + mock_client = MagicMock() + mock_client.accounts.list.return_value = [bad, good] + aiservices = build_service(mock_client) + + result = aiservices._get_accounts() + + assert list(result[AZURE_SUBSCRIPTION_ID]) == [ACCOUNT_ID] diff --git a/uv.lock b/uv.lock index 8643a960c3..68fd1d3159 100644 --- a/uv.lock +++ b/uv.lock @@ -972,6 +972,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/b3/8ec1268082f4d20cc8bf723a1a8e6b9e330bcc338a4dbcee9c7737e9dc1c/azure_mgmt_authorization-4.0.0-py3-none-any.whl", hash = "sha256:d8feeb3842e6ddf1a370963ca4f61fb6edc124e8997b807dd025bc9b2379cd1a", size = 1072620, upload-time = "2023-07-25T04:47:49.26Z" }, ] +[[package]] +name = "azure-mgmt-cognitiveservices" +version = "14.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "azure-mgmt-core" }, + { name = "msrest" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/8e/9fcfdd507413a2536c5458b40942705ea8d74fe4e17f05fd1c32bb0225a9/azure_mgmt_cognitiveservices-14.1.0.tar.gz", hash = "sha256:915191374d0adb443863c20aaea2c9f3b9d558a849ac2b78f152249262fdcaf8", size = 184444, upload-time = "2025-10-24T07:28:14.229Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d3/25/ed6bbc01a03991255dd22aa41091360d225f1be20c88710af0f4a6c27459/azure_mgmt_cognitiveservices-14.1.0-py3-none-any.whl", hash = "sha256:3c0eecc00d183842a11d754f4f5c3328ff86f2d6c0a2b81b28dd7e073680258e", size = 290096, upload-time = "2025-10-24T07:28:15.766Z" }, +] + [[package]] name = "azure-mgmt-compute" version = "34.0.0" @@ -3789,6 +3803,7 @@ dependencies = [ { name = "azure-mgmt-apimanagement" }, { name = "azure-mgmt-applicationinsights" }, { name = "azure-mgmt-authorization" }, + { name = "azure-mgmt-cognitiveservices" }, { name = "azure-mgmt-compute" }, { name = "azure-mgmt-containerregistry" }, { name = "azure-mgmt-containerservice" }, @@ -3913,6 +3928,7 @@ requires-dist = [ { name = "azure-mgmt-apimanagement", specifier = "==5.0.0" }, { name = "azure-mgmt-applicationinsights", specifier = "==4.1.0" }, { name = "azure-mgmt-authorization", specifier = "==4.0.0" }, + { name = "azure-mgmt-cognitiveservices", specifier = "==14.1.0" }, { name = "azure-mgmt-compute", specifier = "==34.0.0" }, { name = "azure-mgmt-containerregistry", specifier = "==12.0.0" }, { name = "azure-mgmt-containerservice", specifier = "==34.1.0" },