From e76ecfdd4d6c07ca036fe68380124b74b23a01ae Mon Sep 17 00:00:00 2001 From: lydiavilchez <114735608+lydiavilchez@users.noreply.github.com> Date: Wed, 21 Jan 2026 15:12:01 +0100 Subject: [PATCH] feat(gcp): add check for OS Login 2FA enabled at project level (#9839) --- prowler/CHANGELOG.md | 1 + .../__init__.py | 0 ...project_os_login_2fa_enabled.metadata.json | 41 +++ .../compute_project_os_login_2fa_enabled.py | 39 +++ .../gcp/services/compute/compute_service.py | 10 +- ...mpute_project_os_login_2fa_enabled_test.py | 285 ++++++++++++++++++ 6 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 prowler/providers/gcp/services/compute/compute_project_os_login_2fa_enabled/__init__.py create mode 100644 prowler/providers/gcp/services/compute/compute_project_os_login_2fa_enabled/compute_project_os_login_2fa_enabled.metadata.json create mode 100644 prowler/providers/gcp/services/compute/compute_project_os_login_2fa_enabled/compute_project_os_login_2fa_enabled.py create mode 100644 tests/providers/gcp/services/compute/compute_project_os_login_2fa_enabled/compute_project_os_login_2fa_enabled_test.py diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 8dfd040ebf..e4d5453116 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -21,6 +21,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `compute_instance_single_network_interface` check for GCP provider [(#9702)](https://github.com/prowler-cloud/prowler/pull/9702) - `compute_image_not_publicly_shared` check for GCP provider [(#9718)](https://github.com/prowler-cloud/prowler/pull/9718) - `compute_snapshot_not_outdated` check for GCP provider [(#9774)](https://github.com/prowler-cloud/prowler/pull/9774) +- `compute_project_os_login_2fa_enabled` check for GCP provider [(#9839)](https://github.com/prowler-cloud/prowler/pull/9839) - `compute_instance_on_host_maintenance_migrate` check for GCP provider [(#9834)](https://github.com/prowler-cloud/prowler/pull/9834) - CIS 1.12 compliance framework for Kubernetes [(#9778)](https://github.com/prowler-cloud/prowler/pull/9778) - CIS 6.0 for M365 provider [(#9779)](https://github.com/prowler-cloud/prowler/pull/9779) diff --git a/prowler/providers/gcp/services/compute/compute_project_os_login_2fa_enabled/__init__.py b/prowler/providers/gcp/services/compute/compute_project_os_login_2fa_enabled/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/compute/compute_project_os_login_2fa_enabled/compute_project_os_login_2fa_enabled.metadata.json b/prowler/providers/gcp/services/compute/compute_project_os_login_2fa_enabled/compute_project_os_login_2fa_enabled.metadata.json new file mode 100644 index 0000000000..bec06a7838 --- /dev/null +++ b/prowler/providers/gcp/services/compute/compute_project_os_login_2fa_enabled/compute_project_os_login_2fa_enabled.metadata.json @@ -0,0 +1,41 @@ +{ + "Provider": "gcp", + "CheckID": "compute_project_os_login_2fa_enabled", + "CheckTitle": "GCP project has OS Login with 2FA enabled", + "CheckType": [], + "ServiceName": "compute", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "high", + "ResourceType": "compute.googleapis.com/Project", + "ResourceGroup": "governance", + "Description": "OS Login **Two-Factor Authentication (2FA)** requires users to verify their identity with a second factor when connecting via SSH to VM instances.\n\nThis provides an additional security layer beyond passwords or SSH keys, significantly reducing the risk of unauthorized access even if credentials are compromised.", + "Risk": "Without 2FA enforcement, compromised credentials (stolen SSH keys or passwords) grant immediate access to VM instances. Attackers could:\n\n- Gain unauthorized shell access to production systems\n- Exfiltrate sensitive data or deploy malware\n- Move laterally within the infrastructure\n\nThis single point of failure significantly increases the attack surface.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cloud.google.com/compute/docs/oslogin/set-up-oslogin", + "https://www.trendmicro.com/cloudoneconformity/knowledge-base/gcp/ComputeEngine/enable-os-login-with-2fa-authentication.html" + ], + "Remediation": { + "Code": { + "CLI": "gcloud compute project-info add-metadata --metadata enable-oslogin=TRUE,enable-oslogin-2fa=TRUE", + "NativeIaC": "", + "Other": "1. Navigate to **Compute Engine** > **Metadata** in Google Cloud Console\n2. Click **Edit**\n3. Add or update metadata entry with key `enable-oslogin-2fa` and value `TRUE`\n4. Ensure `enable-oslogin` is also set to `TRUE`\n5. Click **Save**", + "Terraform": "```hcl\nresource \"google_compute_project_metadata\" \"example_resource\" {\n metadata = {\n enable-oslogin = \"TRUE\"\n enable-oslogin-2fa = \"TRUE\" # Enables 2FA for OS Login\n }\n}\n```" + }, + "Recommendation": { + "Text": "Enable OS Login with 2FA at the project level to enforce multi-factor authentication for all SSH connections. This adds a critical security layer by requiring users to complete a second verification step, protecting against credential theft and unauthorized access.", + "Url": "https://hub.prowler.com/check/compute_project_os_login_2fa_enabled" + } + }, + "Categories": [ + "identity-access" + ], + "DependsOn": [ + "compute_project_os_login_enabled" + ], + "RelatedTo": [ + "compute_project_os_login_enabled" + ], + "Notes": "OS Login 2FA requires OS Login to be enabled first. Users must have 2-Step Verification configured in their Google account. For organizations, 2FA can be enforced via Google Workspace or Cloud Identity policies." +} diff --git a/prowler/providers/gcp/services/compute/compute_project_os_login_2fa_enabled/compute_project_os_login_2fa_enabled.py b/prowler/providers/gcp/services/compute/compute_project_os_login_2fa_enabled/compute_project_os_login_2fa_enabled.py new file mode 100644 index 0000000000..1451b2c5c6 --- /dev/null +++ b/prowler/providers/gcp/services/compute/compute_project_os_login_2fa_enabled/compute_project_os_login_2fa_enabled.py @@ -0,0 +1,39 @@ +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.compute.compute_client import compute_client + + +class compute_project_os_login_2fa_enabled(Check): + """Ensure that OS Login with 2FA is enabled for a GCP project. + + This check verifies that OS Login Two-Factor Authentication (2FA) is enabled + at the project level to enforce an additional layer of security for SSH access + to VM instances. + + - PASS: Project has OS Login 2FA enabled (enable-oslogin-2fa=TRUE). + - FAIL: Project does not have OS Login 2FA enabled. + """ + + def execute(self) -> list[Check_Report_GCP]: + findings = [] + for project in compute_client.compute_projects: + report = Check_Report_GCP( + metadata=self.metadata(), + resource=compute_client.projects[project.id], + project_id=project.id, + location=compute_client.region, + resource_name=( + compute_client.projects[project.id].name + if compute_client.projects[project.id].name + else "GCP Project" + ), + ) + report.status = "PASS" + report.status_extended = f"Project {project.id} has OS Login 2FA enabled." + if not project.enable_oslogin_2fa: + report.status = "FAIL" + report.status_extended = ( + f"Project {project.id} does not have OS Login 2FA enabled." + ) + findings.append(report) + + return findings diff --git a/prowler/providers/gcp/services/compute/compute_service.py b/prowler/providers/gcp/services/compute/compute_service.py index ab1501e129..40a1f96e57 100644 --- a/prowler/providers/gcp/services/compute/compute_service.py +++ b/prowler/providers/gcp/services/compute/compute_service.py @@ -80,6 +80,7 @@ class Compute(GCPService): for project_id in self.project_ids: try: enable_oslogin = False + enable_oslogin_2fa = False response = ( self.client.projects() .get(project=project_id) @@ -88,8 +89,14 @@ class Compute(GCPService): for item in response["commonInstanceMetadata"].get("items", []): if item["key"] == "enable-oslogin" and item["value"] == "TRUE": enable_oslogin = True + if item["key"] == "enable-oslogin-2fa" and item["value"] == "TRUE": + enable_oslogin_2fa = True self.compute_projects.append( - Project(id=project_id, enable_oslogin=enable_oslogin) + Project( + id=project_id, + enable_oslogin=enable_oslogin, + enable_oslogin_2fa=enable_oslogin_2fa, + ) ) except Exception as error: logger.error( @@ -733,6 +740,7 @@ class Firewall(BaseModel): class Project(BaseModel): id: str enable_oslogin: bool + enable_oslogin_2fa: bool = False class LoadBalancer(BaseModel): diff --git a/tests/providers/gcp/services/compute/compute_project_os_login_2fa_enabled/compute_project_os_login_2fa_enabled_test.py b/tests/providers/gcp/services/compute/compute_project_os_login_2fa_enabled/compute_project_os_login_2fa_enabled_test.py new file mode 100644 index 0000000000..8988ff7678 --- /dev/null +++ b/tests/providers/gcp/services/compute/compute_project_os_login_2fa_enabled/compute_project_os_login_2fa_enabled_test.py @@ -0,0 +1,285 @@ +from re import search +from unittest import mock + +from prowler.providers.gcp.models import GCPProject +from tests.providers.gcp.gcp_fixtures import GCP_PROJECT_ID, set_mocked_gcp_provider + + +class Test_compute_project_os_login_2fa_enabled: + def test_compute_no_project(self): + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.projects = [] + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.compute.compute_project_os_login_2fa_enabled.compute_project_os_login_2fa_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_project_os_login_2fa_enabled.compute_project_os_login_2fa_enabled import ( + compute_project_os_login_2fa_enabled, + ) + + check = compute_project_os_login_2fa_enabled() + result = check.execute() + assert len(result) == 0 + + def test_one_compliant_project_2fa_enabled(self): + from prowler.providers.gcp.services.compute.compute_service import Project + + project = Project( + id=GCP_PROJECT_ID, + enable_oslogin=True, + enable_oslogin_2fa=True, + ) + + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.compute_projects = [project] + compute_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test", + labels={}, + lifecycle_state="ACTIVE", + ) + } + compute_client.region = "global" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.compute.compute_project_os_login_2fa_enabled.compute_project_os_login_2fa_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_project_os_login_2fa_enabled.compute_project_os_login_2fa_enabled import ( + compute_project_os_login_2fa_enabled, + ) + + check = compute_project_os_login_2fa_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert search( + f"Project {project.id} has OS Login 2FA enabled", + result[0].status_extended, + ) + assert result[0].resource_id == project.id + assert result[0].resource_name == "test" + assert result[0].location == "global" + assert result[0].project_id == GCP_PROJECT_ID + + def test_one_non_compliant_project_2fa_disabled(self): + from prowler.providers.gcp.services.compute.compute_service import Project + + project = Project( + id=GCP_PROJECT_ID, + enable_oslogin=True, + enable_oslogin_2fa=False, + ) + + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.compute_projects = [project] + compute_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test", + labels={}, + lifecycle_state="ACTIVE", + ) + } + compute_client.region = "global" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.compute.compute_project_os_login_2fa_enabled.compute_project_os_login_2fa_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_project_os_login_2fa_enabled.compute_project_os_login_2fa_enabled import ( + compute_project_os_login_2fa_enabled, + ) + + check = compute_project_os_login_2fa_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert search( + f"Project {project.id} does not have OS Login 2FA enabled", + result[0].status_extended, + ) + assert result[0].resource_id == project.id + assert result[0].resource_name == "test" + assert result[0].location == "global" + assert result[0].project_id == GCP_PROJECT_ID + + def test_one_non_compliant_project_oslogin_disabled_2fa_disabled(self): + from prowler.providers.gcp.services.compute.compute_service import Project + + project = Project( + id=GCP_PROJECT_ID, + enable_oslogin=False, + enable_oslogin_2fa=False, + ) + + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.compute_projects = [project] + compute_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="test", + labels={}, + lifecycle_state="ACTIVE", + ) + } + compute_client.region = "global" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.compute.compute_project_os_login_2fa_enabled.compute_project_os_login_2fa_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_project_os_login_2fa_enabled.compute_project_os_login_2fa_enabled import ( + compute_project_os_login_2fa_enabled, + ) + + check = compute_project_os_login_2fa_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert search( + f"Project {project.id} does not have OS Login 2FA enabled", + result[0].status_extended, + ) + assert result[0].resource_id == project.id + assert result[0].resource_name == "test" + assert result[0].location == "global" + assert result[0].project_id == GCP_PROJECT_ID + + def test_one_compliant_project_empty_project_name(self): + from prowler.providers.gcp.services.compute.compute_service import Project + + project = Project( + id=GCP_PROJECT_ID, + enable_oslogin=True, + enable_oslogin_2fa=True, + ) + + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.compute_projects = [project] + compute_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="", + labels={}, + lifecycle_state="ACTIVE", + ) + } + compute_client.region = "global" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.compute.compute_project_os_login_2fa_enabled.compute_project_os_login_2fa_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_project_os_login_2fa_enabled.compute_project_os_login_2fa_enabled import ( + compute_project_os_login_2fa_enabled, + ) + + check = compute_project_os_login_2fa_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "PASS" + assert search( + f"Project {project.id} has OS Login 2FA enabled", + result[0].status_extended, + ) + assert result[0].resource_id == project.id + assert result[0].resource_name == "GCP Project" + assert result[0].location == "global" + assert result[0].project_id == GCP_PROJECT_ID + + def test_one_non_compliant_project_empty_project_name(self): + from prowler.providers.gcp.services.compute.compute_service import Project + + project = Project( + id=GCP_PROJECT_ID, + enable_oslogin=True, + enable_oslogin_2fa=False, + ) + + compute_client = mock.MagicMock() + compute_client.project_ids = [GCP_PROJECT_ID] + compute_client.compute_projects = [project] + compute_client.projects = { + GCP_PROJECT_ID: GCPProject( + id=GCP_PROJECT_ID, + number="123456789012", + name="", + labels={}, + lifecycle_state="ACTIVE", + ) + } + compute_client.region = "global" + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + "prowler.providers.gcp.services.compute.compute_project_os_login_2fa_enabled.compute_project_os_login_2fa_enabled.compute_client", + new=compute_client, + ), + ): + from prowler.providers.gcp.services.compute.compute_project_os_login_2fa_enabled.compute_project_os_login_2fa_enabled import ( + compute_project_os_login_2fa_enabled, + ) + + check = compute_project_os_login_2fa_enabled() + result = check.execute() + + assert len(result) == 1 + assert result[0].status == "FAIL" + assert search( + f"Project {project.id} does not have OS Login 2FA enabled", + result[0].status_extended, + ) + assert result[0].resource_id == project.id + assert result[0].resource_name == "GCP Project" + assert result[0].location == "global" + assert result[0].project_id == GCP_PROJECT_ID