diff --git a/prowler/providers/aws/services/opensearch/opensearch_service.py b/prowler/providers/aws/services/opensearch/opensearch_service.py index 0a5739b0e1..72bf36b29d 100644 --- a/prowler/providers/aws/services/opensearch/opensearch_service.py +++ b/prowler/providers/aws/services/opensearch/opensearch_service.py @@ -82,6 +82,7 @@ class OpenSearchService(AWSService): regional_client = self.regional_clients[domain.region] describe_domain = regional_client.describe_domain(DomainName=domain.name) domain.arn = describe_domain["DomainStatus"]["ARN"] + if "vpc" in describe_domain["DomainStatus"].get("Endpoints", {}): domain.vpc_endpoints = [ vpc for vpc in describe_domain["DomainStatus"]["Endpoints"].values() @@ -111,19 +112,24 @@ class OpenSearchService(AWSService): ) domain.update_available = ( describe_domain["DomainStatus"] - .get("ServiceSoftwareOptions", {"UpdateAvailable": False}) + .get("ServiceSoftwareOptions", {}) .get("UpdateAvailable", False) ) domain.version = describe_domain["DomainStatus"].get("EngineVersion", None) domain.advanced_settings_enabled = describe_domain["DomainStatus"][ "AdvancedSecurityOptions" ].get("Enabled", False) - domain.instance_count = describe_domain["DomainStatus"][ - "ClusterConfig" - ].get("InstanceCount", None) - domain.zone_awareness_enabled = describe_domain["DomainStatus"][ - "ClusterConfig" - ].get("ZoneAwarenessEnabled", False) + cluster_config = describe_domain["DomainStatus"].get("ClusterConfig", {}) + domain.instance_count = cluster_config.get("InstanceCount", None) + domain.zone_awareness_enabled = cluster_config.get( + "ZoneAwarenessEnabled", False + ) + domain.dedicated_master_enabled = cluster_config.get( + "DedicatedMasterEnabled", False + ) + domain.dedicated_master_count = cluster_config.get( + "DedicatedMasterCount", 0 + ) except Exception as error: logger.error( f"{regional_client.region} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" @@ -168,3 +174,6 @@ class OpenSearchDomain(BaseModel): zone_awareness_enabled: Optional[bool] tags: Optional[list] = [] advanced_settings_enabled: bool = None + dedicated_master_enabled: Optional[bool] + dedicated_master_count: Optional[int] + tags: Optional[list] = [] diff --git a/prowler/providers/aws/services/opensearch/opensearch_service_domains_fault_tolerant_master_nodes/__init__.py b/prowler/providers/aws/services/opensearch/opensearch_service_domains_fault_tolerant_master_nodes/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/aws/services/opensearch/opensearch_service_domains_fault_tolerant_master_nodes/opensearch_service_domains_fault_tolerant_master_nodes.metadata.json b/prowler/providers/aws/services/opensearch/opensearch_service_domains_fault_tolerant_master_nodes/opensearch_service_domains_fault_tolerant_master_nodes.metadata.json new file mode 100644 index 0000000000..76d5e781fb --- /dev/null +++ b/prowler/providers/aws/services/opensearch/opensearch_service_domains_fault_tolerant_master_nodes/opensearch_service_domains_fault_tolerant_master_nodes.metadata.json @@ -0,0 +1,32 @@ +{ + "Provider": "aws", + "CheckID": "opensearch_service_domains_fault_tolerant_master_nodes", + "CheckTitle": "OpenSearch Service Domain should have at least three dedicated master nodes", + "CheckType": [], + "ServiceName": "opensearch", + "SubServiceName": "", + "ResourceIdTemplate": "arn:partition:es:region:account-id:domain/resource-id", + "Severity": "medium", + "ResourceType": "AwsOpenSearchServiceDomain", + "Description": "OpenSearch Service uses dedicated master nodes to increase cluster stability. A minimum of three dedicated master nodes is recommended to ensure high availability.", + "Risk": "If a master node fails, the cluster may become unavailable.", + "RelatedUrl": "https://docs.aws.amazon.com/opensearch-service/latest/developerguide/managedomains-dedicatedmasternodes.html#dedicatedmasternodes-number", + "Remediation": { + "Code": { + "CLI": "aws es update-elasticsearch-domain-config --region --domain-name --elasticsearch-cluster-config DedicatedMasterEnabled=true,DedicatedMasterType='',DedicatedMasterCount=3", + "NativeIaC": "", + "Other": "https://docs.aws.amazon.com/securityhub/latest/userguide/opensearch-controls.html#opensearch-11", + "Terraform": "" + }, + "Recommendation": { + "Text": "Ensure that your OpenSearch Service domain has at least three dedicated master nodes", + "Url": "https://docs.aws.amazon.com/opensearch-service/latest/developerguide/managedomains-dedicatedmasternodes.html#dedicatedmasternodes-number" + } + }, + "Categories": [ + "redundancy" + ], + "DependsOn": [], + "RelatedTo": [], + "Notes": "" +} diff --git a/prowler/providers/aws/services/opensearch/opensearch_service_domains_fault_tolerant_master_nodes/opensearch_service_domains_fault_tolerant_master_nodes.py b/prowler/providers/aws/services/opensearch/opensearch_service_domains_fault_tolerant_master_nodes/opensearch_service_domains_fault_tolerant_master_nodes.py new file mode 100644 index 0000000000..ca98c0e376 --- /dev/null +++ b/prowler/providers/aws/services/opensearch/opensearch_service_domains_fault_tolerant_master_nodes/opensearch_service_domains_fault_tolerant_master_nodes.py @@ -0,0 +1,28 @@ +from prowler.lib.check.models import Check, Check_Report_AWS +from prowler.providers.aws.services.opensearch.opensearch_client import ( + opensearch_client, +) + + +class opensearch_service_domains_fault_tolerant_master_nodes(Check): + def execute(self): + findings = [] + for domain in opensearch_client.opensearch_domains.values(): + report = Check_Report_AWS(self.metadata()) + report.resource_id = domain.name + report.resource_arn = domain.arn + report.region = domain.region + report.resource_tags = domain.tags + report.status = "PASS" + report.status_extended = f"Opensearch domain {domain.name} has {domain.dedicated_master_count} dedicated master nodes, which guarantees fault tolerance on the master nodes." + + if not getattr(domain, "dedicated_master_enabled", False): + report.status = "FAIL" + report.status_extended = f"Opensearch domain {domain.name} has dedicated master nodes disabled." + elif domain.dedicated_master_count < 3: + report.status = "FAIL" + report.status_extended = f"Opensearch domain {domain.name} does not have at least 3 dedicated master nodes." + + findings.append(report) + + return findings diff --git a/tests/providers/aws/services/opensearch/opensearch_service_domains_fault_tolerant_master_nodes/opensearch_service_domains_fault_tolerant_master_nodes_test.py b/tests/providers/aws/services/opensearch/opensearch_service_domains_fault_tolerant_master_nodes/opensearch_service_domains_fault_tolerant_master_nodes_test.py new file mode 100644 index 0000000000..56ab8ab596 --- /dev/null +++ b/tests/providers/aws/services/opensearch/opensearch_service_domains_fault_tolerant_master_nodes/opensearch_service_domains_fault_tolerant_master_nodes_test.py @@ -0,0 +1,160 @@ +from unittest import mock + +from boto3 import client +from moto import mock_aws + +from tests.providers.aws.utils import AWS_REGION_EU_WEST_1, set_mocked_aws_provider + +domain_name = "test-domain" + + +class Test_opensearch_service_domains_fault_tolerant_master_nodes: + @mock_aws + def test_no_domains(self): + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.opensearch.opensearch_service import ( + OpenSearchService, + ) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), mock.patch( + "prowler.providers.aws.services.opensearch.opensearch_service_domains_fault_tolerant_master_nodes.opensearch_service_domains_fault_tolerant_master_nodes.opensearch_client", + new=OpenSearchService(aws_provider), + ): + from prowler.providers.aws.services.opensearch.opensearch_service_domains_fault_tolerant_master_nodes.opensearch_service_domains_fault_tolerant_master_nodes import ( + opensearch_service_domains_fault_tolerant_master_nodes, + ) + + check = opensearch_service_domains_fault_tolerant_master_nodes() + result = check.execute() + assert len(result) == 0 + + @mock_aws + def test_domain_no_master_nodes_enabled(self): + opensearch_client = client("opensearch", region_name=AWS_REGION_EU_WEST_1) + opensearch_client.create_domain( + DomainName=domain_name, + ClusterConfig={ + "DedicatedMasterEnabled": False, + }, + ) + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.opensearch.opensearch_service import ( + OpenSearchService, + ) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), mock.patch( + "prowler.providers.aws.services.opensearch.opensearch_service_domains_fault_tolerant_master_nodes.opensearch_service_domains_fault_tolerant_master_nodes.opensearch_client", + new=OpenSearchService(aws_provider), + ): + from prowler.providers.aws.services.opensearch.opensearch_service_domains_fault_tolerant_master_nodes.opensearch_service_domains_fault_tolerant_master_nodes import ( + opensearch_service_domains_fault_tolerant_master_nodes, + ) + + check = opensearch_service_domains_fault_tolerant_master_nodes() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Opensearch domain {domain_name} has dedicated master nodes disabled." + ) + assert result[0].resource_id == domain_name + assert result[0].region == AWS_REGION_EU_WEST_1 + assert result[0].resource_tags == [] + + @mock_aws + def test_domain_with_one_master_node(self): + opensearch_client = client("opensearch", region_name=AWS_REGION_EU_WEST_1) + domain_arn = opensearch_client.create_domain( + DomainName=domain_name, + ClusterConfig={ + "DedicatedMasterEnabled": True, + "DedicatedMasterCount": 1, + "DedicatedMasterType": "m3.medium.search", + }, + TagList=[ + {"Key": "test", "Value": "test"}, + ], + )["DomainStatus"]["ARN"] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.opensearch.opensearch_service import ( + OpenSearchService, + ) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), mock.patch( + "prowler.providers.aws.services.opensearch.opensearch_service_domains_fault_tolerant_master_nodes.opensearch_service_domains_fault_tolerant_master_nodes.opensearch_client", + new=OpenSearchService(aws_provider), + ): + from prowler.providers.aws.services.opensearch.opensearch_service_domains_fault_tolerant_master_nodes.opensearch_service_domains_fault_tolerant_master_nodes import ( + opensearch_service_domains_fault_tolerant_master_nodes, + ) + + check = opensearch_service_domains_fault_tolerant_master_nodes() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert ( + result[0].status_extended + == f"Opensearch domain {domain_name} does not have at least 3 dedicated master nodes." + ) + assert result[0].resource_id == domain_name + assert result[0].resource_arn == domain_arn + assert result[0].region == AWS_REGION_EU_WEST_1 + assert result[0].resource_tags == [{"Key": "test", "Value": "test"}] + + @mock_aws + def test_domain_with_three_master_nodes(self): + opensearch_client = client("opensearch", region_name=AWS_REGION_EU_WEST_1) + domain_arn = opensearch_client.create_domain( + DomainName=domain_name, + ClusterConfig={ + "DedicatedMasterEnabled": True, + "DedicatedMasterCount": 3, + "DedicatedMasterType": "m3.medium.search", + }, + TagList=[{"Key": "test", "Value": "test"}], + )["DomainStatus"]["ARN"] + + aws_provider = set_mocked_aws_provider([AWS_REGION_EU_WEST_1]) + + from prowler.providers.aws.services.opensearch.opensearch_service import ( + OpenSearchService, + ) + + with mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=aws_provider, + ), mock.patch( + "prowler.providers.aws.services.opensearch.opensearch_service_domains_fault_tolerant_master_nodes.opensearch_service_domains_fault_tolerant_master_nodes.opensearch_client", + new=OpenSearchService(aws_provider), + ): + from prowler.providers.aws.services.opensearch.opensearch_service_domains_fault_tolerant_master_nodes.opensearch_service_domains_fault_tolerant_master_nodes import ( + opensearch_service_domains_fault_tolerant_master_nodes, + ) + + check = opensearch_service_domains_fault_tolerant_master_nodes() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == f"Opensearch domain {domain_name} has 3 dedicated master nodes, which guarantees fault tolerance on the master nodes." + ) + assert result[0].resource_id == domain_name + assert result[0].resource_arn == domain_arn + assert result[0].region == AWS_REGION_EU_WEST_1 + assert result[0].resource_tags == [{"Key": "test", "Value": "test"}] diff --git a/tests/providers/aws/services/opensearch/opensearch_service_test.py b/tests/providers/aws/services/opensearch/opensearch_service_test.py index bd3a3254ab..9f186851e7 100644 --- a/tests/providers/aws/services/opensearch/opensearch_service_test.py +++ b/tests/providers/aws/services/opensearch/opensearch_service_test.py @@ -2,7 +2,6 @@ from json import dumps from unittest.mock import patch import botocore -from boto3 import client from moto import mock_aws from prowler.providers.aws.services.opensearch.opensearch_service import ( @@ -58,6 +57,43 @@ def mock_make_api_call(self, operation_name, kwarg): }, } } + if operation_name == "DescribeDomain": + return { + "DomainStatus": { + "ARN": domain_arn, + "Endpoints": { + "vpc": "vpc-endpoint-h2dsd34efgyghrtguk5gt6j2foh4.eu-west-1.es.amazonaws.com" + }, + "EngineVersion": "opensearch-version1", + "VPCOptions": { + "VPCId": "test-vpc-id", + }, + "ClusterConfig": { + "DedicatedMasterEnabled": True, + "DedicatedMasterCount": 1, + "DedicatedMasterType": "m3.medium.search", + "InstanceCount": 1, + "ZoneAwarenessEnabled": True, + }, + "CognitoOptions": {"Enabled": True}, + "EncryptionAtRestOptions": {"Enabled": True}, + "NodeToNodeEncryptionOptions": {"Enabled": True}, + "AdvancedOptions": {"string": "string"}, + "LogPublishingOptions": { + "string": { + "CloudWatchLogsLogGroupArn": "string", + "Enabled": True | False, + } + }, + "ServiceSoftwareOptions": {"UpdateAvailable": True}, + "DomainEndpointOptions": {"EnforceHTTPS": True}, + "AdvancedSecurityOptions": { + "Enabled": True, + "InternalUserDatabaseEnabled": True, + "SAMLOptions": {"Enabled": True}, + }, + } + } if operation_name == "ListTags": return { "TagList": [ @@ -80,7 +116,7 @@ def mock_generate_regional_clients(provider, service): "prowler.providers.aws.aws_provider.AwsProvider.generate_regional_clients", new=mock_generate_regional_clients, ) -class Test_OpenSearchService_Service: +class TestOpenSearchServiceService: # Test OpenSearchService Service def test_service(self): aws_provider = set_mocked_aws_provider([]) @@ -132,53 +168,31 @@ class Test_OpenSearchService_Service: # Test OpenSearchService describe domain @mock_aws def test_describe_domain(self): - opensearch_client = client("opensearch", region_name=AWS_REGION_EU_WEST_1) - domain = opensearch_client.create_domain( - DomainName=test_domain_name, - EncryptionAtRestOptions={"Enabled": True}, - ClusterConfig={"InstanceCount": 1, "ZoneAwarenessEnabled": True}, - NodeToNodeEncryptionOptions={"Enabled": True}, - AdvancedSecurityOptions={ - "Enabled": True, - "InternalUserDatabaseEnabled": True, - "SAMLOptions": {"Enabled": True}, - }, - CognitoOptions={"Enabled": True}, - VPCOptions={ - "SubnetIds": ["test-subnet-id"], - "SecurityGroupIds": ["test-security-group-id"], - }, - DomainEndpointOptions={"EnforceHTTPS": True}, - AccessPolicies=policy_json, - EngineVersion="opensearch-version1", - TagList=[ - {"Key": "test", "Value": "test"}, - ], - ) aws_provider = set_mocked_aws_provider([]) opensearch = OpenSearchService(aws_provider) - domain_arn = domain["DomainStatus"]["ARN"] assert len(opensearch.opensearch_domains) == 1 assert opensearch.opensearch_domains[domain_arn].name == test_domain_name assert opensearch.opensearch_domains[domain_arn].region == AWS_REGION_EU_WEST_1 assert opensearch.opensearch_domains[domain_arn].arn == domain_arn assert opensearch.opensearch_domains[domain_arn].access_policy assert opensearch.opensearch_domains[domain_arn].vpc_endpoints == [ - "test.eu-west-1.es.amazonaws.com" + "vpc-endpoint-h2dsd34efgyghrtguk5gt6j2foh4.eu-west-1.es.amazonaws.com" ] - assert not opensearch.opensearch_domains[domain_arn].vpc_id + assert opensearch.opensearch_domains[domain_arn].vpc_id == "test-vpc-id" assert opensearch.opensearch_domains[domain_arn].cognito_options assert opensearch.opensearch_domains[domain_arn].encryption_at_rest assert opensearch.opensearch_domains[domain_arn].node_to_node_encryption assert opensearch.opensearch_domains[domain_arn].enforce_https assert opensearch.opensearch_domains[domain_arn].internal_user_database assert opensearch.opensearch_domains[domain_arn].saml_enabled - assert not opensearch.opensearch_domains[domain_arn].update_available + assert opensearch.opensearch_domains[domain_arn].update_available assert ( opensearch.opensearch_domains[domain_arn].version == "opensearch-version1" ) assert opensearch.opensearch_domains[domain_arn].instance_count == 1 assert opensearch.opensearch_domains[domain_arn].zone_awareness_enabled + assert opensearch.opensearch_domains[domain_arn].dedicated_master_enabled + assert opensearch.opensearch_domains[domain_arn].dedicated_master_count == 1 assert opensearch.opensearch_domains[domain_arn].tags == [ {"Key": "test", "Value": "test"}, ]