diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index bb4053e3b8..b14ef2de8f 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -9,6 +9,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `securityhub_delegated_admin_enabled_all_regions` check for AWS provider, verifying that Security Hub has a delegated administrator, is active in all opted-in regions, and has organization auto-enable on [(#11259)](https://github.com/prowler-cloud/prowler/pull/11259) - `config_delegated_admin_and_org_aggregator_all_regions` check for AWS provider, verifying that AWS Config has a delegated administrator and an organization aggregator covering all AWS regions [(#11259)](https://github.com/prowler-cloud/prowler/pull/11259) - `sagemaker_clarify_exists` check for AWS provider [(#11211)](https://github.com/prowler-cloud/prowler/pull/11211) +- `cloudsql_instance_high_availability_enabled` check for GCP provider, verifying Cloud SQL primary instances use `REGIONAL` availability for automatic zone failover [(#11024)](https://github.com/prowler-cloud/prowler/pull/11024) - `identity_storage_service_level_admins_scoped` check for OCI provider CIS 3.1 control 1.15, ensuring storage service-level administrators exclude delete permissions [(#11523)](https://github.com/prowler-cloud/prowler/pull/11523) --- diff --git a/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/__init__.py b/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled.metadata.json b/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled.metadata.json new file mode 100644 index 0000000000..3e66f9cdd3 --- /dev/null +++ b/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled.metadata.json @@ -0,0 +1,38 @@ +{ + "Provider": "gcp", + "CheckID": "cloudsql_instance_high_availability_enabled", + "CheckTitle": "Cloud SQL instance has high availability (REGIONAL) configured", + "CheckType": [], + "ServiceName": "cloudsql", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "medium", + "ResourceType": "sqladmin.googleapis.com/Instance", + "Description": "Ensures that Cloud SQL instances have high availability configured by setting availabilityType to REGIONAL. A REGIONAL instance maintains a standby replica in a different zone within the same region and automatically fails over on zone-level outages.", + "Risk": "Instances with ZONAL availability have no standby replica. A zone-level outage will cause database downtime until manual recovery, violating availability requirements and potentially breaching SLAs and ISMS-P 2.12.1 disaster preparedness controls.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cloud.google.com/sql/docs/postgres/high-availability", + "https://cloud.google.com/sql/docs/sqlserver/high-availability" + ], + "Remediation": { + "Code": { + "CLI": "gcloud sql instances patch --availability-type=REGIONAL", + "NativeIaC": "", + "Other": "1. Go to Google Cloud Console > SQL > Instances.\n2. Click the instance name, then Edit.\n3. Under Availability, select Multiple zones (Highly available).\n4. Click Save.", + "Terraform": "```hcl\nresource \"google_sql_database_instance\" \"example\" {\n name = \"\"\n database_version = \"POSTGRES_15\"\n region = \"\"\n\n settings {\n tier = \"db-custom-2-7680\"\n\n availability_type = \"REGIONAL\" # Critical: enables HA standby replica\n\n backup_configuration {\n enabled = true\n start_time = \"02:00\"\n }\n }\n}\n```" + }, + "Recommendation": { + "Text": "Set availabilityType to REGIONAL for all production Cloud SQL instances. This creates a standby replica in a different zone and enables automatic failover, reducing RTO in the event of a zone outage.", + "Url": "https://hub.prowler.com/check/cloudsql_instance_high_availability_enabled" + } + }, + "Categories": [ + "resilience" + ], + "DependsOn": [], + "RelatedTo": [ + "cloudsql_instance_automated_backups" + ], + "Notes": "Enabling HA increases instance cost approximately 2x due to the standby replica. ZONAL instances are acceptable for non-production workloads where downtime is tolerable." +} diff --git a/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled.py b/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled.py new file mode 100644 index 0000000000..37bf576345 --- /dev/null +++ b/prowler/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled.py @@ -0,0 +1,41 @@ +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.cloudsql.cloudsql_client import cloudsql_client + + +class cloudsql_instance_high_availability_enabled(Check): + """Check that Cloud SQL primary instances are configured for high availability. + + Verifies that each Cloud SQL primary instance has `availabilityType` set to + `REGIONAL`, which provisions a standby replica in a different zone within + the same region and enables automatic failover on zone-level outages. Read + replicas are skipped because they inherit availability from their primary. + """ + + def execute(self) -> list[Check_Report_GCP]: + """Execute the high availability check across all Cloud SQL instances. + + Returns: + A list of `Check_Report_GCP` findings, one per Cloud SQL primary + instance. Status is `PASS` when `availability_type == "REGIONAL"` + and `FAIL` otherwise. + """ + findings = [] + for instance in cloudsql_client.instances: + if instance.instance_type != "CLOUD_SQL_INSTANCE": + continue + report = Check_Report_GCP(metadata=self.metadata(), resource=instance) + if instance.availability_type == "REGIONAL": + report.status = "PASS" + report.status_extended = ( + f"Database instance {instance.name} has high availability " + f"(REGIONAL) configured." + ) + else: + report.status = "FAIL" + report.status_extended = ( + f"Database instance {instance.name} does not have high " + f"availability configured (current: " + f"{instance.availability_type})." + ) + findings.append(report) + return findings diff --git a/prowler/providers/gcp/services/cloudsql/cloudsql_service.py b/prowler/providers/gcp/services/cloudsql/cloudsql_service.py index 137169999a..1fe706bb02 100644 --- a/prowler/providers/gcp/services/cloudsql/cloudsql_service.py +++ b/prowler/providers/gcp/services/cloudsql/cloudsql_service.py @@ -46,6 +46,9 @@ class CloudSQL(GCPService): "authorizedNetworks", [] ), flags=settings.get("databaseFlags", []), + availability_type=settings.get( + "availabilityType", "ZONAL" + ), instance_type=instance.get( "instanceType", "CLOUD_SQL_INSTANCE" ), @@ -76,6 +79,7 @@ class Instance(BaseModel): ssl_mode: str automated_backups: bool flags: list + availability_type: str = "ZONAL" instance_type: str = "CLOUD_SQL_INSTANCE" cmek_key_name: Optional[str] = None project_id: str diff --git a/tests/providers/gcp/gcp_fixtures.py b/tests/providers/gcp/gcp_fixtures.py index ba6480ee22..1abcaf5bf0 100644 --- a/tests/providers/gcp/gcp_fixtures.py +++ b/tests/providers/gcp/gcp_fixtures.py @@ -722,6 +722,7 @@ def mock_api_instances_calls(client: MagicMock, service: str): }, "backupConfiguration": {"enabled": True}, "databaseFlags": [], + "availabilityType": "REGIONAL", }, }, { @@ -737,6 +738,7 @@ def mock_api_instances_calls(client: MagicMock, service: str): }, "backupConfiguration": {"enabled": False}, "databaseFlags": [], + "availabilityType": "ZONAL", }, }, ] diff --git a/tests/providers/gcp/services/cloudsql/cloudsql_instance_cmek_encryption_enabled/__init__.py b/tests/providers/gcp/services/cloudsql/cloudsql_instance_cmek_encryption_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/__init__.py b/tests/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled_test.py b/tests/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled_test.py new file mode 100644 index 0000000000..82c49fb89e --- /dev/null +++ b/tests/providers/gcp/services/cloudsql/cloudsql_instance_high_availability_enabled/cloudsql_instance_high_availability_enabled_test.py @@ -0,0 +1,205 @@ +from unittest import mock + +from tests.providers.gcp.gcp_fixtures import ( + GCP_EU1_LOCATION, + GCP_PROJECT_ID, + set_mocked_gcp_provider, +) + + +class Test_cloudsql_instance_high_availability_enabled: + """Tests for the cloudsql_instance_high_availability_enabled check.""" + + def test_no_instances(self): + """No Cloud SQL instances → no findings.""" + cloudsql_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled.cloudsql_client", + new=cloudsql_client, + ), + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled import ( + cloudsql_instance_high_availability_enabled, + ) + + cloudsql_client.instances = [] + check = cloudsql_instance_high_availability_enabled() + result = check.execute() + assert len(result) == 0 + + def test_instance_ha_enabled(self): + """A REGIONAL primary instance → PASS.""" + cloudsql_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled.cloudsql_client", + new=cloudsql_client, + ), + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled import ( + cloudsql_instance_high_availability_enabled, + ) + from prowler.providers.gcp.services.cloudsql.cloudsql_service import ( + Instance, + ) + + cloudsql_client.instances = [ + Instance( + name="db-ha", + version="POSTGRES_15", + ip_addresses=[], + region=GCP_EU1_LOCATION, + public_ip=False, + require_ssl=False, + ssl_mode="ENCRYPTED_ONLY", + automated_backups=True, + authorized_networks=[], + flags=[], + project_id=GCP_PROJECT_ID, + availability_type="REGIONAL", + ) + ] + check = cloudsql_instance_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert result[0].resource_id == "db-ha" + assert result[0].location == GCP_EU1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_instance_ha_disabled(self): + """A ZONAL primary instance → FAIL with current availability in status_extended.""" + cloudsql_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled.cloudsql_client", + new=cloudsql_client, + ), + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled import ( + cloudsql_instance_high_availability_enabled, + ) + from prowler.providers.gcp.services.cloudsql.cloudsql_service import ( + Instance, + ) + + cloudsql_client.instances = [ + Instance( + name="db-zonal", + version="POSTGRES_15", + ip_addresses=[], + region=GCP_EU1_LOCATION, + public_ip=False, + require_ssl=False, + ssl_mode="ENCRYPTED_ONLY", + automated_backups=True, + authorized_networks=[], + flags=[], + project_id=GCP_PROJECT_ID, + availability_type="ZONAL", + ) + ] + check = cloudsql_instance_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "ZONAL" in result[0].status_extended + assert result[0].resource_id == "db-zonal" + assert result[0].location == GCP_EU1_LOCATION + assert result[0].project_id == GCP_PROJECT_ID + + def test_read_replica_skipped(self): + """Read replicas (instance_type != CLOUD_SQL_INSTANCE) are skipped.""" + cloudsql_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled.cloudsql_client", + new=cloudsql_client, + ), + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled import ( + cloudsql_instance_high_availability_enabled, + ) + from prowler.providers.gcp.services.cloudsql.cloudsql_service import ( + Instance, + ) + + cloudsql_client.instances = [ + Instance( + name="db-replica", + version="POSTGRES_15", + ip_addresses=[], + region=GCP_EU1_LOCATION, + public_ip=False, + require_ssl=False, + ssl_mode="ENCRYPTED_ONLY", + automated_backups=True, + authorized_networks=[], + flags=[], + project_id=GCP_PROJECT_ID, + availability_type="ZONAL", + instance_type="READ_REPLICA_INSTANCE", + ) + ] + check = cloudsql_instance_high_availability_enabled() + result = check.execute() + assert len(result) == 0 + + def test_instance_default_availability_type_fails(self): + """An instance missing availabilityType defaults to ZONAL (service layer) and must FAIL.""" + cloudsql_client = mock.MagicMock() + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled.cloudsql_client", + new=cloudsql_client, + ), + ): + from prowler.providers.gcp.services.cloudsql.cloudsql_instance_high_availability_enabled.cloudsql_instance_high_availability_enabled import ( + cloudsql_instance_high_availability_enabled, + ) + from prowler.providers.gcp.services.cloudsql.cloudsql_service import ( + Instance, + ) + + cloudsql_client.instances = [ + Instance( + name="db-default", + version="POSTGRES_15", + ip_addresses=[], + region=GCP_EU1_LOCATION, + public_ip=False, + require_ssl=False, + ssl_mode="ENCRYPTED_ONLY", + automated_backups=True, + authorized_networks=[], + flags=[], + project_id=GCP_PROJECT_ID, + # availability_type omitted → model default "ZONAL" + ) + ] + check = cloudsql_instance_high_availability_enabled() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert "ZONAL" in result[0].status_extended