feat(gcp): add secretmanager_secret_rotation_enabled check (#11026)

Co-authored-by: Lydia Vilchez <lydiavilchezlopez@gmail.com>
This commit is contained in:
s1ns3nz0
2026-06-23 18:30:15 +09:00
committed by GitHub
parent c6c07957a6
commit 48acb3bd2e
10 changed files with 589 additions and 0 deletions
+1
View File
@@ -14,6 +14,7 @@ All notable changes to the **Prowler SDK** are documented in this file.
- `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)
- `secretmanager_secret_rotation_enabled` check for GCP provider, verifying Secret Manager secrets have automatic rotation configured within 90 days [(#11026)](https://github.com/prowler-cloud/prowler/pull/11026)
- `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)
+3
View File
@@ -582,6 +582,9 @@ gcp:
# GCP Storage Sufficient Retention Period
# gcp.cloudstorage_bucket_sufficient_retention_period
storage_min_retention_days: 90
# GCP Secret Manager Rotation Period
# gcp.secretmanager_secret_rotation_enabled
secretmanager_max_rotation_days: 90
# Kubernetes Configuration
kubernetes:
@@ -0,0 +1,40 @@
{
"Provider": "gcp",
"CheckID": "secretmanager_secret_rotation_enabled",
"CheckTitle": "Secret Manager secret is rotated every 90 days or less",
"CheckType": [],
"ServiceName": "secretmanager",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "secretmanager.googleapis.com/Secret",
"Description": "Secret Manager secrets have **automatic rotation** configured with a rotation period of `90` days or less and the next scheduled rotation has not been missed.\n\nThe evaluation reviews each secret's `rotation` settings to confirm both the period and the upcoming rotation time are within bounds.",
"Risk": "Without timely rotation, a leaked or compromised secret remains valid indefinitely, eroding **confidentiality** and widening the **blast radius** of any credential exposure. Stale secrets also bypass periodic re-authorization controls expected by frameworks such as PCI-DSS and ISO 27001.",
"RelatedUrl": "",
"AdditionalURLs": [
"https://cloud.google.com/secret-manager/docs/rotation-recommendations",
"https://cloud.google.com/secret-manager/docs/reference/rest/v1/projects.secrets"
],
"Remediation": {
"Code": {
"CLI": "gcloud secrets update <SECRET_NAME> --rotation-period=7776000s --next-rotation-time=<RFC3339_TIMESTAMP>",
"NativeIaC": "",
"Other": "1. In Google Cloud Console, go to Security > Secret Manager\n2. Select the secret and click Edit secret\n3. Under Rotation, enable Automatic rotation with a period of 90 days or less\n4. Configure a Pub/Sub topic to receive rotation notifications\n5. Click Save",
"Terraform": "```hcl\nresource \"google_secret_manager_secret\" \"<example_resource_name>\" {\n secret_id = \"<example_resource_id>\"\n\n replication {\n auto {}\n }\n\n rotation {\n rotation_period = \"7776000s\" # Critical: enables rotation within 90 days\n next_rotation_time = \"<RFC3339_TIMESTAMP>\"\n }\n}\n```"
},
"Recommendation": {
"Text": "Enable **automatic rotation** for every Secret Manager secret with a period of `90` days or less.\n\nWire **Pub/Sub notifications** to trigger rotation logic and apply **defense in depth** by versioning each secret update so consumers can roll back without losing availability.",
"Url": "https://hub.prowler.com/check/secretmanager_secret_rotation_enabled"
}
},
"Categories": [
"secrets"
],
"DependsOn": [],
"RelatedTo": [
"secretmanager_secret_not_publicly_accessible",
"kms_key_rotation_enabled",
"iam_sa_user_managed_key_rotate_90_days"
],
"Notes": "A secret without a rotationPeriod field has no automatic rotation configured and will be marked as FAIL. Rotation periods exceeding 90 days are also marked as FAIL."
}
@@ -0,0 +1,83 @@
import datetime
from prowler.lib.check.models import Check, Check_Report_GCP
from prowler.providers.gcp.services.secretmanager.secretmanager_client import (
secretmanager_client,
)
class secretmanager_secret_rotation_enabled(Check):
"""
Ensure Secret Manager secrets have automatic rotation configured within the max rotation period.
- PASS: Secret has a rotation period within the maximum (default 90 days) and the next rotation is not overdue.
- FAIL: Secret has no rotation, the period exceeds the maximum, or the next rotation has been missed.
"""
def execute(self) -> list[Check_Report_GCP]:
"""Evaluate every Secret Manager secret's rotation configuration against the maximum rotation period."""
findings = []
max_rotation_days = int(
getattr(secretmanager_client, "audit_config", {}).get(
"secretmanager_max_rotation_days", 90
)
)
for secret in secretmanager_client.secrets:
report = Check_Report_GCP(
metadata=self.metadata(),
resource=secret,
resource_id=secret.name,
)
rotation_seconds = None
if secret.rotation_period:
try:
rotation_seconds = float(secret.rotation_period[:-1])
except (ValueError, IndexError):
rotation_seconds = None
rotation_overdue = False
if rotation_seconds is not None and secret.next_rotation_time:
try:
parsed = secret.next_rotation_time.replace("Z", "+00:00")
next_rotation_time = datetime.datetime.fromisoformat(parsed)
rotation_overdue = next_rotation_time < datetime.datetime.now(
datetime.timezone.utc
)
except (ValueError, AttributeError):
rotation_overdue = True
max_rotation_seconds = max_rotation_days * 86400
rotation_days = (
int(rotation_seconds // 86400) if rotation_seconds is not None else None
)
if rotation_seconds is None:
report.status = "FAIL"
report.status_extended = (
f"Secret {secret.name} does not have automatic rotation enabled."
)
elif rotation_seconds > max_rotation_seconds:
report.status = "FAIL"
report.status_extended = (
f"Secret {secret.name} has rotation enabled but the period "
f"({rotation_days} days) exceeds the {max_rotation_days}-day maximum."
)
elif rotation_overdue:
report.status = "FAIL"
report.status_extended = (
f"Secret {secret.name} has rotation configured "
f"({rotation_days} days) but the scheduled rotation is overdue."
)
else:
report.status = "PASS"
report.status_extended = (
f"Secret {secret.name} has automatic rotation enabled "
f"with a period of {rotation_days} days."
)
findings.append(report)
return findings
@@ -1,3 +1,5 @@
from typing import Optional
from pydantic.v1 import BaseModel
from prowler.lib.logger import logger
@@ -33,11 +35,14 @@ class SecretManager(GCPService):
while request is not None:
response = request.execute(num_retries=DEFAULT_RETRY_ATTEMPTS)
for secret in response.get("secrets", []):
rotation = secret.get("rotation") or {}
self.secrets.append(
Secret(
id=secret["name"],
name=secret["name"].split("/")[-1],
project_id=project_id,
rotation_period=rotation.get("rotationPeriod"),
next_rotation_time=rotation.get("nextRotationTime"),
)
)
request = (
@@ -84,4 +89,6 @@ class Secret(BaseModel):
name: str
project_id: str
location: str = "global"
rotation_period: Optional[str] = None
next_rotation_time: Optional[str] = None
publicly_accessible: bool = False
+1
View File
@@ -92,6 +92,7 @@ class TestGCPProvider:
"shodan_api_key": None,
"max_unused_account_days": 180,
"storage_min_retention_days": 90,
"secretmanager_max_rotation_days": 90,
"mig_min_zones": 2,
"max_snapshot_age_days": 90,
}
@@ -0,0 +1,385 @@
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_rotation_enabled."
"secretmanager_secret_rotation_enabled"
)
_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_rotation_enabled:
def test_no_secrets(self):
secretmanager_client = mock.MagicMock()
secretmanager_client.audit_config = {"secretmanager_max_rotation_days": 90}
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_rotation_enabled.secretmanager_secret_rotation_enabled import (
secretmanager_secret_rotation_enabled,
)
secretmanager_client.secrets = []
check = secretmanager_secret_rotation_enabled()
result = check.execute()
assert len(result) == 0
def test_rotation_within_90_days_pass(self):
secretmanager_client = mock.MagicMock()
secretmanager_client.audit_config = {"secretmanager_max_rotation_days": 90}
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_rotation_enabled.secretmanager_secret_rotation_enabled import (
secretmanager_secret_rotation_enabled,
)
from prowler.providers.gcp.services.secretmanager.secretmanager_service import (
Secret,
)
secretmanager_client.secrets = [
Secret(
id=_secret_id("secret-rotated"),
name="secret-rotated",
project_id=GCP_PROJECT_ID,
rotation_period="7776000s",
)
]
check = secretmanager_secret_rotation_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== "Secret secret-rotated has automatic rotation enabled with a period of 90 days."
)
assert result[0].resource_id == "secret-rotated"
assert result[0].resource_name == "secret-rotated"
assert result[0].location == "global"
assert result[0].project_id == GCP_PROJECT_ID
def test_rotation_period_exceeds_max_fail(self):
secretmanager_client = mock.MagicMock()
secretmanager_client.audit_config = {"secretmanager_max_rotation_days": 90}
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_rotation_enabled.secretmanager_secret_rotation_enabled import (
secretmanager_secret_rotation_enabled,
)
from prowler.providers.gcp.services.secretmanager.secretmanager_service import (
Secret,
)
secretmanager_client.secrets = [
Secret(
id=_secret_id("secret-stale"),
name="secret-stale",
project_id=GCP_PROJECT_ID,
rotation_period="9504000s",
)
]
check = secretmanager_secret_rotation_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "exceeds the 90-day maximum" in result[0].status_extended
assert result[0].resource_id == "secret-stale"
def test_rotation_period_one_second_over_max_fail(self):
"""90 days + 1 second must fail — comparison is on seconds, not floored days."""
secretmanager_client = mock.MagicMock()
secretmanager_client.audit_config = {"secretmanager_max_rotation_days": 90}
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_rotation_enabled.secretmanager_secret_rotation_enabled import (
secretmanager_secret_rotation_enabled,
)
from prowler.providers.gcp.services.secretmanager.secretmanager_service import (
Secret,
)
# 90 days = 7_776_000 seconds. Add 1 second.
secretmanager_client.secrets = [
Secret(
id=_secret_id("secret-90d-plus-1s"),
name="secret-90d-plus-1s",
project_id=GCP_PROJECT_ID,
rotation_period="7776001s",
)
]
check = secretmanager_secret_rotation_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "exceeds the 90-day maximum" in result[0].status_extended
def test_no_rotation_fail(self):
secretmanager_client = mock.MagicMock()
secretmanager_client.audit_config = {"secretmanager_max_rotation_days": 90}
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_rotation_enabled.secretmanager_secret_rotation_enabled import (
secretmanager_secret_rotation_enabled,
)
from prowler.providers.gcp.services.secretmanager.secretmanager_service import (
Secret,
)
secretmanager_client.secrets = [
Secret(
id=_secret_id("secret-no-rotation"),
name="secret-no-rotation",
project_id=GCP_PROJECT_ID,
rotation_period=None,
)
]
check = secretmanager_secret_rotation_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Secret secret-no-rotation does not have automatic rotation enabled."
)
def test_fractional_seconds_period_pass(self):
secretmanager_client = mock.MagicMock()
secretmanager_client.audit_config = {"secretmanager_max_rotation_days": 90}
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_rotation_enabled.secretmanager_secret_rotation_enabled import (
secretmanager_secret_rotation_enabled,
)
from prowler.providers.gcp.services.secretmanager.secretmanager_service import (
Secret,
)
secretmanager_client.secrets = [
Secret(
id=_secret_id("secret-fractional"),
name="secret-fractional",
project_id=GCP_PROJECT_ID,
rotation_period="2592000.500000000s",
)
]
check = secretmanager_secret_rotation_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
def test_sub_day_rotation_period_pass(self):
secretmanager_client = mock.MagicMock()
secretmanager_client.audit_config = {"secretmanager_max_rotation_days": 90}
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_rotation_enabled.secretmanager_secret_rotation_enabled import (
secretmanager_secret_rotation_enabled,
)
from prowler.providers.gcp.services.secretmanager.secretmanager_service import (
Secret,
)
secretmanager_client.secrets = [
Secret(
id=_secret_id("secret-sub-day"),
name="secret-sub-day",
project_id=GCP_PROJECT_ID,
rotation_period="3600s",
)
]
check = secretmanager_secret_rotation_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
def test_overdue_next_rotation_fail(self):
secretmanager_client = mock.MagicMock()
secretmanager_client.audit_config = {"secretmanager_max_rotation_days": 90}
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_rotation_enabled.secretmanager_secret_rotation_enabled import (
secretmanager_secret_rotation_enabled,
)
from prowler.providers.gcp.services.secretmanager.secretmanager_service import (
Secret,
)
secretmanager_client.secrets = [
Secret(
id=_secret_id("secret-overdue"),
name="secret-overdue",
project_id=GCP_PROJECT_ID,
rotation_period="7776000s",
next_rotation_time="2020-01-01T00:00:00Z",
)
]
check = secretmanager_secret_rotation_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "overdue" in result[0].status_extended
def test_invalid_rotation_period_format_fail(self):
"""Unparseable rotation_period falls back to None → FAIL with no-rotation message."""
secretmanager_client = mock.MagicMock()
secretmanager_client.audit_config = {"secretmanager_max_rotation_days": 90}
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_rotation_enabled.secretmanager_secret_rotation_enabled import (
secretmanager_secret_rotation_enabled,
)
from prowler.providers.gcp.services.secretmanager.secretmanager_service import (
Secret,
)
secretmanager_client.secrets = [
Secret(
id=_secret_id("secret-bad-period"),
name="secret-bad-period",
project_id=GCP_PROJECT_ID,
rotation_period="not-a-duration",
)
]
check = secretmanager_secret_rotation_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== "Secret secret-bad-period does not have automatic rotation enabled."
)
def test_invalid_next_rotation_time_fail_closed(self):
"""Unparseable next_rotation_time fails closed → FAIL as overdue."""
secretmanager_client = mock.MagicMock()
secretmanager_client.audit_config = {"secretmanager_max_rotation_days": 90}
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_rotation_enabled.secretmanager_secret_rotation_enabled import (
secretmanager_secret_rotation_enabled,
)
from prowler.providers.gcp.services.secretmanager.secretmanager_service import (
Secret,
)
secretmanager_client.secrets = [
Secret(
id=_secret_id("secret-bad-timestamp"),
name="secret-bad-timestamp",
project_id=GCP_PROJECT_ID,
rotation_period="7776000s",
next_rotation_time="not-a-timestamp",
)
]
check = secretmanager_secret_rotation_enabled()
result = check.execute()
assert len(result) == 1
assert result[0].status == "FAIL"
assert "overdue" in result[0].status_extended
@@ -60,8 +60,77 @@ class TestSecretManagerService:
assert secret.id == f"projects/{GCP_PROJECT_ID}/secrets/my-secret"
assert secret.project_id == GCP_PROJECT_ID
assert secret.location == "global"
assert secret.rotation_period is None
assert secret.next_rotation_time is None
assert secret.publicly_accessible is False
def test_get_secrets_with_rotation(self):
def mock_api_client(*args, **kwargs):
return _make_secretmanager_client(
secrets_list=[
{
"name": f"projects/{GCP_PROJECT_ID}/secrets/secret-with-rotation",
"rotation": {
"rotationPeriod": "7776000s",
"nextRotationTime": "2026-09-01T00:00:00Z",
},
}
]
)
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 == "secret-with-rotation"
assert secret.rotation_period == "7776000s"
assert secret.next_rotation_time == "2026-09-01T00:00:00Z"
def test_get_secrets_with_null_rotation(self):
"""API returning explicit `rotation: null` must not break enumeration."""
def mock_api_client(*args, **kwargs):
return _make_secretmanager_client(
secrets_list=[
{
"name": f"projects/{GCP_PROJECT_ID}/secrets/null-rotation",
"rotation": None,
}
]
)
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 == "null-rotation"
assert secret.rotation_period is None
assert secret.next_rotation_time is None
def test_get_secrets_iam_policy_all_users(self):
def mock_api_client(*args, **kwargs):
return _make_secretmanager_client(