diff --git a/prowler/CHANGELOG.md b/prowler/CHANGELOG.md index 3df8df9c2e..ba6c2a2916 100644 --- a/prowler/CHANGELOG.md +++ b/prowler/CHANGELOG.md @@ -13,6 +13,7 @@ All notable changes to the **Prowler SDK** are documented in this file. - `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) - `cloudfunction_function_inside_vpc` check for GCP provider, verifying Cloud Functions have a Serverless VPC Access connector for private egress [(#11021)](https://github.com/prowler-cloud/prowler/pull/11021) - `cloudfunction_function_not_publicly_accessible` check for GCP provider, detecting Cloud Functions with `allUsers` or `allAuthenticatedUsers` IAM invocation bindings [(#11022)](https://github.com/prowler-cloud/prowler/pull/11022) +- `secretmanager_secret_not_publicly_accessible` check for GCP provider, detecting Secret Manager secrets with public IAM bindings [(#11025)](https://github.com/prowler-cloud/prowler/pull/11025) - `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) - `cosmosdb_account_automatic_failover_enabled` check for Azure provider [(#11031)](https://github.com/prowler-cloud/prowler/pull/11031) - `cosmosdb_account_backup_policy_continuous` check for Azure provider [(#11032)](https://github.com/prowler-cloud/prowler/pull/11032) diff --git a/prowler/providers/gcp/services/secretmanager/__init__.py b/prowler/providers/gcp/services/secretmanager/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/secretmanager/secretmanager_client.py b/prowler/providers/gcp/services/secretmanager/secretmanager_client.py new file mode 100644 index 0000000000..dc18866cb3 --- /dev/null +++ b/prowler/providers/gcp/services/secretmanager/secretmanager_client.py @@ -0,0 +1,6 @@ +from prowler.providers.common.provider import Provider +from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + SecretManager, +) + +secretmanager_client = SecretManager(Provider.get_global_provider()) diff --git a/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/__init__.py b/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible.metadata.json b/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible.metadata.json new file mode 100644 index 0000000000..9f1f4d3381 --- /dev/null +++ b/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible.metadata.json @@ -0,0 +1,40 @@ +{ + "Provider": "gcp", + "CheckID": "secretmanager_secret_not_publicly_accessible", + "CheckTitle": "Secret Manager secret is not publicly accessible", + "CheckType": [], + "ServiceName": "secretmanager", + "SubServiceName": "", + "ResourceIdTemplate": "", + "Severity": "critical", + "ResourceType": "secretmanager.googleapis.com/Secret", + "Description": "Secret Manager secrets deny access to `allUsers` and `allAuthenticatedUsers`, so only **explicitly authorized identities** can read or use them.\n\nThe evaluation reviews each secret's IAM policy bindings to confirm no public principals are granted access.", + "Risk": "Granting public IAM access to a secret exposes credentials, API keys, certificates, or other sensitive values to any caller on the internet or any authenticated Google account. This compromises **confidentiality**, enables **lateral movement** with leaked credentials, and can trigger regulatory breaches under PCI-DSS, ISO 27001, and GDPR.", + "RelatedUrl": "", + "AdditionalURLs": [ + "https://cloud.google.com/secret-manager/docs/access-control", + "https://cloud.google.com/iam/docs/overview" + ], + "Remediation": { + "Code": { + "CLI": "gcloud secrets remove-iam-policy-binding --member= --role=roles/secretmanager.secretAccessor", + "NativeIaC": "", + "Other": "1. In Google Cloud Console, go to Security > Secret Manager\n2. Select the secret and open the Permissions tab\n3. Remove any binding with allUsers or allAuthenticatedUsers\n4. Grant access only to required service accounts or groups", + "Terraform": "```hcl\nresource \"google_secret_manager_secret_iam_binding\" \"\" {\n secret_id = \"\"\n role = \"roles/secretmanager.secretAccessor\"\n members = [\"serviceAccount:\"] # Critical: never include allUsers or allAuthenticatedUsers\n}\n```" + }, + "Recommendation": { + "Text": "Apply **least privilege** to Secret Manager: grant `roles/secretmanager.secretAccessor` only to the specific service accounts or groups that need to read each secret.\n\nUse **workload identity** for GKE workloads and rotate credentials regularly to limit blast radius when a binding is misconfigured.", + "Url": "https://hub.prowler.com/check/secretmanager_secret_not_publicly_accessible" + } + }, + "Categories": [ + "internet-exposed" + ], + "DependsOn": [], + "RelatedTo": [ + "secretmanager_secret_rotation_enabled", + "kms_key_not_publicly_accessible", + "cloudstorage_bucket_public_access" + ], + "Notes": "This check evaluates the secret-level IAM policy. Project-level IAM policies that may indirectly grant public access are not evaluated by this check." +} diff --git a/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible.py b/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible.py new file mode 100644 index 0000000000..fb52a119fa --- /dev/null +++ b/prowler/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible.py @@ -0,0 +1,41 @@ +from prowler.lib.check.models import Check, Check_Report_GCP +from prowler.providers.gcp.services.secretmanager.secretmanager_client import ( + secretmanager_client, +) + + +class secretmanager_secret_not_publicly_accessible(Check): + """Check that Secret Manager secrets do not grant access to all users. + + Verifies that no Secret Manager secret has an IAM binding granting access + to `allUsers` or `allAuthenticatedUsers`. + """ + + def execute(self) -> list[Check_Report_GCP]: + """Execute the public-access check across all Secret Manager secrets. + + Returns: + A list of `Check_Report_GCP` findings, one per secret. Status is + `FAIL` when the secret is accessible to `allUsers` or + `allAuthenticatedUsers` and `PASS` otherwise. + """ + findings = [] + for secret in secretmanager_client.secrets: + report = Check_Report_GCP( + metadata=self.metadata(), + resource=secret, + resource_id=secret.name, + ) + if secret.publicly_accessible: + report.status = "FAIL" + report.status_extended = ( + f"Secret {secret.name} is publicly accessible " + f"(allUsers or allAuthenticatedUsers IAM binding detected)." + ) + else: + report.status = "PASS" + report.status_extended = ( + f"Secret {secret.name} is not publicly accessible." + ) + findings.append(report) + return findings diff --git a/prowler/providers/gcp/services/secretmanager/secretmanager_service.py b/prowler/providers/gcp/services/secretmanager/secretmanager_service.py new file mode 100644 index 0000000000..6a193d8ece --- /dev/null +++ b/prowler/providers/gcp/services/secretmanager/secretmanager_service.py @@ -0,0 +1,87 @@ +from pydantic.v1 import BaseModel + +from prowler.lib.logger import logger +from prowler.providers.gcp.config import DEFAULT_RETRY_ATTEMPTS +from prowler.providers.gcp.gcp_provider import GcpProvider +from prowler.providers.gcp.lib.service.service import GCPService + + +class SecretManager(GCPService): + """Secret Manager service client. + + Enumerates Secret Manager secrets across every accessible project using + the `secretmanager.googleapis.com` v1 API and exposes them through the + `secrets` attribute. + """ + + def __init__(self, provider: GcpProvider) -> None: + """Initialize the service and preload secrets with their IAM policies.""" + super().__init__("secretmanager", provider) + self.secrets = [] + self._get_secrets() + self._get_secrets_iam_policy() + + def _get_secrets(self) -> None: + """Fetch Secret Manager secrets for every project.""" + for project_id in self.project_ids: + try: + request = ( + self.client.projects() + .secrets() + .list(parent=f"projects/{project_id}") + ) + while request is not None: + response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS) + for secret in response.get("secrets", []): + self.secrets.append( + Secret( + id=secret["name"], + name=secret["name"].split("/")[-1], + project_id=project_id, + ) + ) + request = ( + self.client.projects() + .secrets() + .list_next(previous_request=request, previous_response=response) + ) + except Exception as error: + logger.error( + f"{project_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def _get_secrets_iam_policy(self) -> None: + """Fetch IAM policy for every Secret Manager secret in parallel.""" + self.__threading_call__(self._get_secret_iam_policy, self.secrets) + + def _get_secret_iam_policy(self, secret: "Secret") -> None: + """Mark a secret as publicly accessible when bound to `allUsers` or `allAuthenticatedUsers`.""" + try: + response = ( + self.client.projects() + .secrets() + .getIamPolicy(resource=secret.id) + .execute( + http=self.__get_AuthorizedHttp_client__(), + num_retries=DEFAULT_RETRY_ATTEMPTS, + ) + ) + for binding in response.get("bindings", []): + members = binding.get("members", []) + if "allUsers" in members or "allAuthenticatedUsers" in members: + secret.publicly_accessible = True + break + except Exception as error: + logger.error( + f"{secret.project_id} -- {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + +class Secret(BaseModel): + """Secret Manager secret resource consumed by GCP checks.""" + + id: str + name: str + project_id: str + location: str = "global" + publicly_accessible: bool = False diff --git a/tests/providers/gcp/services/secretmanager/__init__.py b/tests/providers/gcp/services/secretmanager/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/__init__.py b/tests/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible_test.py b/tests/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible_test.py new file mode 100644 index 0000000000..f925966f3c --- /dev/null +++ b/tests/providers/gcp/services/secretmanager/secretmanager_secret_not_publicly_accessible/secretmanager_secret_not_publicly_accessible_test.py @@ -0,0 +1,169 @@ +from unittest import mock + +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + set_mocked_gcp_provider, +) + +_CHECK_PATH = ( + "prowler.providers.gcp.services.secretmanager." + "secretmanager_secret_not_publicly_accessible." + "secretmanager_secret_not_publicly_accessible" +) +_CLIENT_PATH = f"{_CHECK_PATH}.secretmanager_client" + + +def _secret_id(name: str) -> str: + return f"projects/{GCP_PROJECT_ID}/secrets/{name}" + + +class Test_secretmanager_secret_not_publicly_accessible: + def test_no_secrets(self): + secretmanager_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_not_publicly_accessible.secretmanager_secret_not_publicly_accessible import ( + secretmanager_secret_not_publicly_accessible, + ) + + secretmanager_client.secrets = [] + + check = secretmanager_secret_not_publicly_accessible() + result = check.execute() + assert len(result) == 0 + + def test_secret_private(self): + secretmanager_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_not_publicly_accessible.secretmanager_secret_not_publicly_accessible import ( + secretmanager_secret_not_publicly_accessible, + ) + from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + Secret, + ) + + secretmanager_client.secrets = [ + Secret( + id=_secret_id("secret-private"), + name="secret-private", + project_id=GCP_PROJECT_ID, + publicly_accessible=False, + ) + ] + + check = secretmanager_secret_not_publicly_accessible() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "PASS" + assert ( + result[0].status_extended + == "Secret secret-private is not publicly accessible." + ) + assert result[0].resource_id == "secret-private" + assert result[0].resource_name == "secret-private" + assert result[0].location == "global" + assert result[0].project_id == GCP_PROJECT_ID + + def test_secret_publicly_accessible(self): + secretmanager_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_not_publicly_accessible.secretmanager_secret_not_publicly_accessible import ( + secretmanager_secret_not_publicly_accessible, + ) + from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + Secret, + ) + + secretmanager_client.secrets = [ + Secret( + id=_secret_id("secret-public"), + name="secret-public", + project_id=GCP_PROJECT_ID, + publicly_accessible=True, + ) + ] + + check = secretmanager_secret_not_publicly_accessible() + result = check.execute() + assert len(result) == 1 + assert result[0].status == "FAIL" + assert result[0].status_extended == ( + "Secret secret-public is publicly accessible " + "(allUsers or allAuthenticatedUsers IAM binding detected)." + ) + assert result[0].resource_id == "secret-public" + assert result[0].resource_name == "secret-public" + assert result[0].location == "global" + assert result[0].project_id == GCP_PROJECT_ID + + def test_multiple_secrets_mixed(self): + secretmanager_client = mock.MagicMock() + + with ( + mock.patch( + "prowler.providers.common.provider.Provider.get_global_provider", + return_value=set_mocked_gcp_provider(), + ), + mock.patch( + _CLIENT_PATH, + new=secretmanager_client, + ), + ): + from prowler.providers.gcp.services.secretmanager.secretmanager_secret_not_publicly_accessible.secretmanager_secret_not_publicly_accessible import ( + secretmanager_secret_not_publicly_accessible, + ) + from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + Secret, + ) + + secretmanager_client.secrets = [ + Secret( + id=_secret_id("secret-private"), + name="secret-private", + project_id=GCP_PROJECT_ID, + publicly_accessible=False, + ), + Secret( + id=_secret_id("secret-public"), + name="secret-public", + project_id=GCP_PROJECT_ID, + publicly_accessible=True, + ), + ] + + check = secretmanager_secret_not_publicly_accessible() + result = check.execute() + assert len(result) == 2 + + by_id = {r.resource_id: r for r in result} + assert by_id["secret-private"].status == "PASS" + assert by_id["secret-public"].status == "FAIL" diff --git a/tests/providers/gcp/services/secretmanager/secretmanager_service_test.py b/tests/providers/gcp/services/secretmanager/secretmanager_service_test.py new file mode 100644 index 0000000000..213134c245 --- /dev/null +++ b/tests/providers/gcp/services/secretmanager/secretmanager_service_test.py @@ -0,0 +1,162 @@ +from unittest.mock import MagicMock, patch + +from prowler.providers.gcp.services.secretmanager.secretmanager_service import ( + SecretManager, +) +from tests.providers.gcp.gcp_fixtures import ( + GCP_PROJECT_ID, + mock_is_api_active, + set_mocked_gcp_provider, +) + + +def _make_secretmanager_client(secrets_list, iam_bindings=None): + """Return a mock GCP API client for the Secret Manager service.""" + client = MagicMock() + + client.projects().secrets().list().execute.return_value = {"secrets": secrets_list} + client.projects().secrets().list_next.return_value = None + + iam_response = {"bindings": iam_bindings or []} + + def mock_get_iam_policy(resource): + rv = MagicMock() + rv.execute.return_value = iam_response + return rv + + client.projects().secrets().getIamPolicy = mock_get_iam_policy + + return client + + +class TestSecretManagerService: + def test_get_secrets(self): + def mock_api_client(*args, **kwargs): + return _make_secretmanager_client( + secrets_list=[ + { + "name": f"projects/{GCP_PROJECT_ID}/secrets/my-secret", + } + ] + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + sm_client = SecretManager( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(sm_client.secrets) == 1 + secret = sm_client.secrets[0] + assert secret.name == "my-secret" + assert secret.id == f"projects/{GCP_PROJECT_ID}/secrets/my-secret" + assert secret.project_id == GCP_PROJECT_ID + assert secret.location == "global" + assert secret.publicly_accessible is False + + def test_get_secrets_iam_policy_all_users(self): + def mock_api_client(*args, **kwargs): + return _make_secretmanager_client( + secrets_list=[ + { + "name": f"projects/{GCP_PROJECT_ID}/secrets/public-secret", + } + ], + iam_bindings=[ + { + "role": "roles/secretmanager.secretAccessor", + "members": ["allUsers"], + } + ], + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + sm_client = SecretManager( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(sm_client.secrets) == 1 + assert sm_client.secrets[0].publicly_accessible is True + + def test_get_secrets_iam_policy_all_authenticated_users(self): + def mock_api_client(*args, **kwargs): + return _make_secretmanager_client( + secrets_list=[ + { + "name": f"projects/{GCP_PROJECT_ID}/secrets/auth-users-secret", + } + ], + iam_bindings=[ + { + "role": "roles/secretmanager.secretAccessor", + "members": ["allAuthenticatedUsers"], + } + ], + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + sm_client = SecretManager( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(sm_client.secrets) == 1 + assert sm_client.secrets[0].publicly_accessible is True + + def test_get_secrets_iam_policy_not_public(self): + def mock_api_client(*args, **kwargs): + return _make_secretmanager_client( + secrets_list=[ + { + "name": f"projects/{GCP_PROJECT_ID}/secrets/private-secret", + } + ], + iam_bindings=[ + { + "role": "roles/secretmanager.secretAccessor", + "members": ["user:alice@example.com"], + } + ], + ) + + with ( + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__is_api_active__", + new=mock_is_api_active, + ), + patch( + "prowler.providers.gcp.lib.service.service.GCPService.__generate_client__", + new=mock_api_client, + ), + ): + sm_client = SecretManager( + set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID]) + ) + + assert len(sm_client.secrets) == 1 + assert sm_client.secrets[0].publicly_accessible is False