diff --git a/poetry.lock b/poetry.lock index c95f6652e9..74ac548e93 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "about-time" @@ -561,6 +561,22 @@ azure-mgmt-core = ">=1.3.2" isodate = ">=0.6.1" typing-extensions = ">=4.6.0" +[[package]] +name = "azure-mgmt-search" +version = "9.1.0" +description = "Microsoft Azure Search Management Client Library for Python" +optional = false +python-versions = ">=3.7" +files = [ + {file = "azure-mgmt-search-9.1.0.tar.gz", hash = "sha256:53bc6eeadb0974d21f120bb21bb5e6827df6d650e17347460fd83e2d68883599"}, + {file = "azure_mgmt_search-9.1.0-py3-none-any.whl", hash = "sha256:488ff81477e980e2b7abf0b857387c74ebbad419e6f6126044e3e6fad2da72b6"}, +] + +[package.dependencies] +azure-common = ">=1.1,<2.0" +azure-mgmt-core = ">=1.3.2,<2.0.0" +isodate = ">=0.6.1,<1.0.0" + [[package]] name = "azure-mgmt-security" version = "7.0.0" @@ -2078,8 +2094,6 @@ optional = false python-versions = "*" files = [ {file = "jsonpath-ng-1.7.0.tar.gz", hash = "sha256:f6f5f7fd4e5ff79c785f1573b394043b39849fb2bb47bcead935d12b00beab3c"}, - {file = "jsonpath_ng-1.7.0-py2-none-any.whl", hash = "sha256:898c93fc173f0c336784a3fa63d7434297544b7198124a68f9a3ef9597b0ae6e"}, - {file = "jsonpath_ng-1.7.0-py3-none-any.whl", hash = "sha256:f3d7f9e848cba1b6da28c55b1c26ff915dc9e0b1ba7e752a53d6da8d5cbd00b6"}, ] [package.dependencies] @@ -5178,4 +5192,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = ">=3.9,<3.13" -content-hash = "c7a86b55cbd211d342a9e00de00c7d13fcca90ba26649d80d144b70cae07606c" +content-hash = "0313d13861b3253896ca0b19d2f9317d576dfd37cc9003e89944306f1c2da666" diff --git a/prowler/providers/azure/services/aisearch/__init__.py b/prowler/providers/azure/services/aisearch/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/aisearch/aisearch_client.py b/prowler/providers/azure/services/aisearch/aisearch_client.py new file mode 100644 index 0000000000..bb1baa456e --- /dev/null +++ b/prowler/providers/azure/services/aisearch/aisearch_client.py @@ -0,0 +1,4 @@ +from prowler.providers.azure.services.aisearch.aisearch_service import AISearch +from prowler.providers.common.provider import Provider + +aisearch_client = AISearch(Provider.get_global_provider()) diff --git a/prowler/providers/azure/services/aisearch/aisearch_service.py b/prowler/providers/azure/services/aisearch/aisearch_service.py new file mode 100644 index 0000000000..23c968e33b --- /dev/null +++ b/prowler/providers/azure/services/aisearch/aisearch_service.py @@ -0,0 +1,48 @@ +from dataclasses import dataclass + +from azure.mgmt.search import SearchManagementClient + +from prowler.lib.logger import logger +from prowler.providers.azure.azure_provider import AzureProvider +from prowler.providers.azure.lib.service.service import AzureService + + +class AISearch(AzureService): + def __init__(self, provider: AzureProvider): + super().__init__(SearchManagementClient, provider) + self.aisearch_services = self._get_aisearch_services() + + def _get_aisearch_services(self): + logger.info("AISearch - Getting services ...") + aisearch_services = {} + for subscription, client in self.clients.items(): + try: + aisearch_services.update({subscription: {}}) + aisearch_services_list = client.services.list_by_subscription() + for aisearch_service in aisearch_services_list: + aisearch_services[subscription].update( + { + aisearch_service.id: AISearchService( + name=aisearch_service.name, + location=aisearch_service.location, + public_network_access=( + False + if aisearch_service.public_network_access + == "Disabled" + else True + ), + ) + } + ) + except Exception as error: + logger.error( + f"Subscription name: {subscription} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return aisearch_services + + +@dataclass +class AISearchService: + name: str + location: str + public_network_access: bool diff --git a/prowler/providers/azure/services/aisearch/aisearch_service_not_publicly_accessible/__init__.py b/prowler/providers/azure/services/aisearch/aisearch_service_not_publicly_accessible/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/azure/services/aisearch/aisearch_service_not_publicly_accessible/aisearch_service_not_publicly_accessible.metadata.json b/prowler/providers/azure/services/aisearch/aisearch_service_not_publicly_accessible/aisearch_service_not_publicly_accessible.metadata.json new file mode 100644 index 0000000000..0e7cb6c399 --- /dev/null +++ b/prowler/providers/azure/services/aisearch/aisearch_service_not_publicly_accessible/aisearch_service_not_publicly_accessible.metadata.json @@ -0,0 +1,30 @@ +{ + "Provider": "azure", + "CheckID": "aisearch_service_not_publicly_accessible", + "CheckTitle": "Restrict public network access to the AI Search Service", + "CheckType": [], + "ServiceName": "aisearch", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "", + "Description": "Ensure that public network access to the Search Service is restricted.", + "Risk": "Public accessibility exposes the Search Service to potential attacks, unauthorized usage, and data breaches. Restricting access minimizes the surface area for attacks and ensures that only authorized networks can access the search service.", + "RelatedUrl": "https://learn.microsoft.com/en-us/azure/search/service-configure-firewall#configure-network-access-in-azure-portal", + "Remediation": { + "Code": { + "CLI": "az search service update --resource-group --name --public-access disabled", + "NativeIaC": "", + "Other": "", + "Terraform": "" + }, + "Recommendation": { + "Text": "Ensure that the necessary virtual network configurations or IP rules are in place to allow access from required services once public access is restricted. Review the network access settings regularly to maintain a secure environment. To restrict public network access to your Search Service: 1. Navigate to your Search Service y in the Azure Portal. 2. Under 'Settings'->'Networking', configure the 'Public network access' settings to 'Disabled'. 3. Set up virtual network service endpoints or private endpoints as needed for secure access. 4. Review and adjust IP access rules as necessary.", + "Url": "https://learn.microsoft.com/en-us/azure/search/service-configure-firewall#configure-network-access-in-azure-portal" + } + }, + "Categories": [], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/azure/services/aisearch/aisearch_service_not_publicly_accessible/aisearch_service_not_publicly_accessible.py b/prowler/providers/azure/services/aisearch/aisearch_service_not_publicly_accessible/aisearch_service_not_publicly_accessible.py new file mode 100644 index 0000000000..0a8b16fcab --- /dev/null +++ b/prowler/providers/azure/services/aisearch/aisearch_service_not_publicly_accessible/aisearch_service_not_publicly_accessible.py @@ -0,0 +1,30 @@ +from typing import List + +from prowler.lib.check.models import Check, Check_Report_Azure +from prowler.providers.azure.services.aisearch.aisearch_client import aisearch_client + + +class aisearch_service_not_publicly_accessible(Check): + def execute(self) -> List[Check_Report_Azure]: + findings = [] + + for ( + subscription_name, + aisearch_services, + ) in aisearch_client.aisearch_services.items(): + for aisearch_service_id, aisearch_service in aisearch_services.items(): + report = Check_Report_Azure(self.metadata()) + report.subscription = subscription_name + report.resource_name = aisearch_service.name + report.resource_id = aisearch_service_id + report.location = aisearch_service.location + report.status = "FAIL" + report.status_extended = f"AISearch Service {aisearch_service.name} from subscription {subscription_name} allows public access." + + if not aisearch_service.public_network_access: + report.status = "PASS" + report.status_extended = f"AISearch Service {aisearch_service.name} from subscription {subscription_name} does not allows public access." + + findings.append(report) + + return findings diff --git a/pyproject.toml b/pyproject.toml index a512dadf74..813d449d72 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ azure-mgmt-monitor = "6.0.2" azure-mgmt-network = "28.0.0" azure-mgmt-rdbms = "10.1.0" azure-mgmt-resource = "23.2.0" +azure-mgmt-search = "9.1.0" azure-mgmt-security = "7.0.0" azure-mgmt-sql = "3.0.1" azure-mgmt-storage = "21.2.1" diff --git a/tests/providers/azure/services/aisearch/aisearch_service_public_access_level_is_disabled/aisearch_service_public_access_level_is_disabled_test.py b/tests/providers/azure/services/aisearch/aisearch_service_public_access_level_is_disabled/aisearch_service_public_access_level_is_disabled_test.py new file mode 100644 index 0000000000..88c5ecbc1e --- /dev/null +++ b/tests/providers/azure/services/aisearch/aisearch_service_public_access_level_is_disabled/aisearch_service_public_access_level_is_disabled_test.py @@ -0,0 +1,104 @@ +from unittest import mock +from uuid import uuid4 + +from prowler.providers.azure.services.aisearch.aisearch_service import AISearchService +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +class Test_AISearch_service_not_publicly_accessible: + def test_aisearch_sevice_no_aisearch_services(self): + aisearch_client = mock.MagicMock + aisearch_client.aisearch_services = {} + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), mock.patch( + "prowler.providers.azure.services.aisearch.aisearch_service_not_publicly_accessible.aisearch_service_not_publicly_accessible.aisearch_client", + new=aisearch_client, + ): + from prowler.providers.azure.services.aisearch.aisearch_service_not_publicly_accessible.aisearch_service_not_publicly_accessible import ( + aisearch_service_not_publicly_accessible, + ) + + check = aisearch_service_not_publicly_accessible() + result = check.execute() + assert len(result) == 0 + + def test_aisearch_service_not_publicly_accessible_enabled(self): + aisearch_service_id = str(uuid4()) + aisearch_service_name = "Test AISearch Service" + aisearch_client = mock.MagicMock + aisearch_client.aisearch_services = { + AZURE_SUBSCRIPTION_ID: { + aisearch_service_id: AISearchService( + name=aisearch_service_name, + location="westeurope", + public_network_access=True, + ) + } + } + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), mock.patch( + "prowler.providers.azure.services.aisearch.aisearch_service_not_publicly_accessible.aisearch_service_not_publicly_accessible.aisearch_client", + new=aisearch_client, + ): + from prowler.providers.azure.services.aisearch.aisearch_service_not_publicly_accessible.aisearch_service_not_publicly_accessible import ( + aisearch_service_not_publicly_accessible, + ) + + check = aisearch_service_not_publicly_accessible() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"AISearch Service {aisearch_service_name} from subscription {AZURE_SUBSCRIPTION_ID} allows public access." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == aisearch_service_name + assert result[0].location == "westeurope" + + def test_aisearch_service_not_publicly_accessible_disabled(self): + aisearch_service_id = str(uuid4()) + aisearch_service_name = "Test Search Service" + aisearch_client = mock.MagicMock + aisearch_client.aisearch_services = { + AZURE_SUBSCRIPTION_ID: { + aisearch_service_id: AISearchService( + name=aisearch_service_name, + location="westeurope", + public_network_access=False, + ) + } + } + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_azure_provider(), + ), mock.patch( + "prowler.providers.azure.services.aisearch.aisearch_service_not_publicly_accessible.aisearch_service_not_publicly_accessible.aisearch_client", + new=aisearch_client, + ): + from prowler.providers.azure.services.aisearch.aisearch_service_not_publicly_accessible.aisearch_service_not_publicly_accessible import ( + aisearch_service_not_publicly_accessible, + ) + + check = aisearch_service_not_publicly_accessible() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"AISearch Service {aisearch_service_name} from subscription {AZURE_SUBSCRIPTION_ID} does not allows public access." + ) + assert result[0].subscription == AZURE_SUBSCRIPTION_ID + assert result[0].resource_name == aisearch_service_name + assert result[0].resource_id == aisearch_service_id + assert result[0].location == "westeurope" diff --git a/tests/providers/azure/services/aisearch/aisearch_service_test.py b/tests/providers/azure/services/aisearch/aisearch_service_test.py new file mode 100644 index 0000000000..dd29039d1b --- /dev/null +++ b/tests/providers/azure/services/aisearch/aisearch_service_test.py @@ -0,0 +1,59 @@ +from unittest.mock import patch + +from prowler.providers.azure.services.aisearch.aisearch_service import ( + AISearch, + AISearchService, +) +from tests.providers.azure.azure_fixtures import ( + AZURE_SUBSCRIPTION_ID, + set_mocked_azure_provider, +) + + +def mock_storage_get_aisearch_services(_): + return { + AZURE_SUBSCRIPTION_ID: { + "aisearch_service_id-1": AISearchService( + name="name", + location="westeurope", + public_network_access=True, + ) + } + } + + +@patch( + "prowler.providers.azure.services.aisearch.aisearch_service.AISearch._get_aisearch_services", + new=mock_storage_get_aisearch_services, +) +class Test_AISearch_Service: + def test_get_client(self): + aisearch = AISearch(set_mocked_azure_provider()) + assert ( + aisearch.clients[AZURE_SUBSCRIPTION_ID].__class__.__name__ + == "SearchManagementClient" + ) + + def test_get_aisearch_services(self): + aisearch = AISearch(set_mocked_azure_provider()) + assert ( + aisearch.aisearch_services[AZURE_SUBSCRIPTION_ID][ + "aisearch_service_id-1" + ].__class__.__name__ + == "AISearchService" + ) + assert ( + aisearch.aisearch_services[AZURE_SUBSCRIPTION_ID][ + "aisearch_service_id-1" + ].name + == "name" + ) + assert ( + aisearch.aisearch_services[AZURE_SUBSCRIPTION_ID][ + "aisearch_service_id-1" + ].location + == "westeurope" + ) + assert aisearch.aisearch_services[AZURE_SUBSCRIPTION_ID][ + "aisearch_service_id-1" + ].public_network_access