mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
feat(gcp): add secretmanager_secret_not_publicly_accessible check (#11025)
Co-authored-by: Lydia Vilchez <lydiavilchezlopez@gmail.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
+40
@@ -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 <SECRET_NAME> --member=<allUsers|allAuthenticatedUsers> --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\" \"<example_resource_name>\" {\n secret_id = \"<example_resource_id>\"\n role = \"roles/secretmanager.secretAccessor\"\n members = [\"serviceAccount:<example_resource_id>\"] # 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."
|
||||
}
|
||||
+41
@@ -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
|
||||
@@ -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
|
||||
+169
@@ -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"
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user