feat(gcp): add check for dormant (unused) SA keys (#7348)

Co-authored-by: MrCloudSec <hello@mistercloudsec.com>
Co-authored-by: Sergio Garcia <sergargar1@gmail.com>
This commit is contained in:
Bogdan A
2025-03-31 19:14:21 +03:00
committed by Pepe Fagoaga
parent b3d7bb4e8d
commit 0b2dac83bd
9 changed files with 459 additions and 1 deletions
+1 -1
View File
@@ -72,7 +72,7 @@ It contains hundreds of controls covering CIS, NIST 800, NIST CSF, CISA, RBI, Fe
| Provider | Checks | Services | [Compliance Frameworks](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/compliance/) | [Categories](https://docs.prowler.com/projects/prowler-open-source/en/latest/tutorials/misc/#categories) |
|---|---|---|---|---|
| AWS | 564 | 82 | 33 | 10 |
| GCP | 77 | 13 | 6 | 3 |
| GCP | 78 | 13 | 6 | 3 |
| Azure | 140 | 18 | 7 | 3 |
| Kubernetes | 83 | 7 | 4 | 7 |
| Microsoft365 | 5 | 2 | 1 | 0 |
@@ -141,6 +141,7 @@
"iam_organization_essential_contacts_configured",
"iam_role_kms_enforce_separation_of_duties",
"iam_role_sa_enforce_separation_of_duties",
"iam_sa_user_managed_key_unused",
"iam_sa_no_administrative_privileges",
"iam_sa_no_user_managed_keys",
"iam_sa_user_managed_key_rotate_90_days",
@@ -0,0 +1,30 @@
{
"Provider": "gcp",
"CheckID": "iam_sa_user_managed_key_unused",
"CheckTitle": "Ensure That There Are No Unused Service Account Keys for Each Service Account",
"CheckType": [],
"ServiceName": "iam",
"SubServiceName": "",
"ResourceIdTemplate": "",
"Severity": "medium",
"ResourceType": "ServiceAccountKey",
"Description": "Ensure That There Are No Dormant Service Account Keys for Each Service Account. A key is considered dormant if it has been inactive for more than 180 days.",
"Risk": "Anyone who has access to the keys will be able to access resources through the service account. GCP-managed keys are used by Cloud Platform services such as App Engine and Compute Engine. These keys cannot be downloaded. Google will keep the keys and automatically rotate them on an approximately weekly basis. User-managed keys are created, downloadable, and managed by users.",
"RelatedUrl": "https://cloud.google.com/iam/docs/service-account-overview#identify-unused",
"Remediation": {
"Code": {
"CLI": "",
"NativeIaC": "",
"Other": "https://www.trendmicro.com/cloudoneconformity/knowledge-base/gcp/CloudIAM/delete-user-managed-service-account-keys.html",
"Terraform": ""
},
"Recommendation": {
"Text": "It is recommended to prevent user-managed service account keys.",
"Url": "https://cloud.google.com/iam/docs/creating-managing-service-account-keys"
}
},
"Categories": [],
"DependsOn": [],
"RelatedTo": [],
"Notes": ""
}
@@ -0,0 +1,30 @@
from prowler.lib.check.models import Check, Check_Report_GCP
from prowler.providers.gcp.services.iam.iam_client import iam_client
from prowler.providers.gcp.services.monitoring.monitoring_client import (
monitoring_client,
)
class iam_sa_user_managed_key_unused(Check):
def execute(self) -> Check_Report_GCP:
findings = []
keys_used = monitoring_client.sa_keys_metrics
for account in iam_client.service_accounts:
for key in account.keys:
if key.type == "USER_MANAGED":
report = Check_Report_GCP(
metadata=self.metadata(),
resource=account,
resource_id=key.name,
resource_name=account.email,
location=iam_client.region,
)
if key.name in keys_used:
report.status = "PASS"
report.status_extended = f"User-managed key {key.name} for Service Account {account.email} was used over the last 180 days."
else:
report.status = "FAIL"
report.status_extended = f"User-managed key {key.name} for Service Account {account.email} was not used over the last 180 days."
findings.append(report)
return findings
@@ -1,3 +1,5 @@
import datetime
from pydantic import BaseModel
from prowler.lib.logger import logger
@@ -9,7 +11,11 @@ class Monitoring(GCPService):
def __init__(self, provider: GcpProvider):
super().__init__(__class__.__name__, provider, api_version="v3")
self.alert_policies = []
self.sa_keys_metrics = set()
self._get_alert_policies()
self._get_sa_keys_metrics(
"iam.googleapis.com/service_account/key/authn_events_count"
)
def _get_alert_policies(self):
for project_id in self.project_ids:
@@ -46,6 +52,50 @@ class Monitoring(GCPService):
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
def _get_sa_keys_metrics(self, metric_type):
try:
end_time = (
datetime.datetime.now(datetime.timezone.utc)
.replace(microsecond=0)
.isoformat()
)
start_time = (
(
datetime.datetime.now(datetime.timezone.utc)
- datetime.timedelta(days=180)
)
.replace(microsecond=0)
.isoformat()
)
for project_id in self.project_ids:
try:
request = (
self.client.projects()
.timeSeries()
.list(
name=f"projects/{project_id}",
filter=f'metric.type = "{metric_type}"',
interval_startTime=start_time,
interval_endTime=end_time,
view="HEADERS",
)
)
response = request.execute()
for metric in response.get("timeSeries", []):
key_id = metric["metric"]["labels"].get("key_id")
if key_id:
self.sa_keys_metrics.add(key_id)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
except Exception as error:
logger.error(
f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}"
)
class AlertPolicy(BaseModel):
name: str
+39
View File
@@ -351,6 +351,45 @@ def mock_api_projects_calls(client: MagicMock):
},
]
}
client.projects().timeSeries().list().execute.return_value = {
"timeSeries": [
{
"metric": {
"labels": {
"key_id": "key1",
"type": "iam.googleapis.com/service_account/key/authn_events_count",
},
"resource": {
"type": "iam_service_account",
"labels": {
"project_id": "{GCP_PROJECT_ID}",
"unique_id": "111222233334444",
},
},
"metricKind": "DELTA",
"valueType": "INT64",
}
},
{
"metric": {
"labels": {
"key_id": "key2",
"type": "iam.googleapis.com/service_account/key/authn_events_count",
},
"resource": {
"type": "iam_service_account",
"labels": {
"project_id": "{GCP_PROJECT_ID}",
"unique_id": "111222233334444",
},
},
"metricKind": "DELTA",
"valueType": "INT64",
}
},
]
}
client.projects().alertPolicies().list_next.return_value = None
# Used by IAM
client.projects().serviceAccounts().list().execute.return_value = {
@@ -0,0 +1,286 @@
from datetime import datetime
from unittest import mock
from tests.providers.gcp.gcp_fixtures import (
GCP_PROJECT_ID,
GCP_US_CENTER1_LOCATION,
set_mocked_gcp_provider,
)
class Test_iam_sa_user_managed_key_unused:
def test_iam_no_sa(self):
iam_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.iam.iam_sa_user_managed_key_unused.iam_sa_user_managed_key_unused.iam_client",
new=iam_client,
),
):
from prowler.providers.gcp.services.iam.iam_sa_user_managed_key_unused.iam_sa_user_managed_key_unused import (
iam_sa_user_managed_key_unused,
)
iam_client.project_ids = [GCP_PROJECT_ID]
iam_client.region = GCP_US_CENTER1_LOCATION
iam_client.service_accounts = []
check = iam_sa_user_managed_key_unused()
result = check.execute()
assert len(result) == 0
def test_iam_sa_dormant_no_keys(self):
iam_client = mock.MagicMock()
monitoring_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.iam.iam_sa_user_managed_key_unused.iam_sa_user_managed_key_unused.iam_client",
new=iam_client,
),
mock.patch(
"prowler.providers.gcp.services.iam.iam_sa_user_managed_key_unused.iam_sa_user_managed_key_unused.monitoring_client",
new=monitoring_client,
),
):
from prowler.providers.gcp.services.iam.iam_sa_user_managed_key_unused.iam_sa_user_managed_key_unused import (
iam_sa_user_managed_key_unused,
)
from prowler.providers.gcp.services.iam.iam_service import ServiceAccount
iam_client.project_ids = [GCP_PROJECT_ID]
iam_client.region = GCP_US_CENTER1_LOCATION
iam_client.service_accounts = [
ServiceAccount(
name="projects/my-project/serviceAccounts/my-service-account@my-project.iam.gserviceaccount.com",
email="my-service-account@my-project.iam.gserviceaccount.com",
display_name="My service account",
keys=[],
project_id=GCP_PROJECT_ID,
)
]
monitoring_client.sa_keys_metrics = set(
["90c48f61c65cd56224a12ab18e6ee9ca9c3aee7c"]
)
check = iam_sa_user_managed_key_unused()
result = check.execute()
assert len(result) == 0
def test_iam_sa_dormant_system_managed_keys(self):
iam_client = mock.MagicMock()
monitoring_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.iam.iam_sa_user_managed_key_unused.iam_sa_user_managed_key_unused.iam_client",
new=iam_client,
),
mock.patch(
"prowler.providers.gcp.services.iam.iam_sa_user_managed_key_unused.iam_sa_user_managed_key_unused.monitoring_client",
new=monitoring_client,
),
):
from prowler.providers.gcp.services.iam.iam_sa_user_managed_key_unused.iam_sa_user_managed_key_unused import (
iam_sa_user_managed_key_unused,
)
from prowler.providers.gcp.services.iam.iam_service import (
Key,
ServiceAccount,
)
iam_client.project_ids = [GCP_PROJECT_ID]
iam_client.region = GCP_US_CENTER1_LOCATION
iam_client.service_accounts = [
ServiceAccount(
name="projects/my-project/serviceAccounts/my-service-account@my-project.iam.gserviceaccount.com",
email="my-service-account@my-project.iam.gserviceaccount.com",
display_name="My service account",
keys=[
Key(
name="90c48f61c65cd56224a12ab18e6ee9ca9c3aee7c",
origin="GOOGLE_PROVIDED",
type="SYSTEM_MANAGED",
valid_after=datetime.strptime("2024-07-10", "%Y-%m-%d"),
valid_before=datetime.strptime("9999-12-31", "%Y-%m-%d"),
)
],
project_id=GCP_PROJECT_ID,
)
]
monitoring_client.sa_keys_metrics = set(
["90c48f61c65cd56224a12ab18e6ee9ca9c3aee7c"]
)
check = iam_sa_user_managed_key_unused()
result = check.execute()
assert len(result) == 0
def test_iam_sa_user_managed_key_unused(self):
iam_client = mock.MagicMock()
monitoring_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.iam.iam_sa_user_managed_key_unused.iam_sa_user_managed_key_unused.iam_client",
new=iam_client,
),
mock.patch(
"prowler.providers.gcp.services.iam.iam_sa_user_managed_key_unused.iam_sa_user_managed_key_unused.monitoring_client",
new=monitoring_client,
),
):
from prowler.providers.gcp.services.iam.iam_sa_user_managed_key_unused.iam_sa_user_managed_key_unused import (
iam_sa_user_managed_key_unused,
)
from prowler.providers.gcp.services.iam.iam_service import (
Key,
ServiceAccount,
)
iam_client.project_ids = [GCP_PROJECT_ID]
iam_client.region = GCP_US_CENTER1_LOCATION
iam_client.service_accounts = [
ServiceAccount(
name="projects/my-project/serviceAccounts/my-service-account@my-project.iam.gserviceaccount.com",
email="my-service-account@my-project.iam.gserviceaccount.com",
display_name="My service account",
keys=[
Key(
name="90c48f61c65cd56224a12ab18e6ee9ca9c3aee7c",
origin="GOOGLE_PROVIDED",
type="USER_MANAGED",
valid_after=datetime.strptime("2024-07-10", "%Y-%m-%d"),
valid_before=datetime.strptime("9999-12-31", "%Y-%m-%d"),
)
],
project_id=GCP_PROJECT_ID,
)
]
monitoring_client.sa_keys_metrics = set(
["90c48f61c65cd56224a12ab18e6ee9ca9c3aee7c"]
)
check = iam_sa_user_managed_key_unused()
result = check.execute()
assert len(result) == 1
assert result[0].status == "PASS"
assert (
result[0].status_extended
== f"User-managed key {iam_client.service_accounts[0].keys[0].name} for Service Account {iam_client.service_accounts[0].email} was used over the last 180 days."
)
assert result[0].resource_id == iam_client.service_accounts[0].keys[0].name
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_US_CENTER1_LOCATION
assert result[0].resource_name == iam_client.service_accounts[0].email
def test_iam_sa_dormant_mixed_keys(self):
iam_client = mock.MagicMock()
monitoring_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.iam.iam_sa_user_managed_key_unused.iam_sa_user_managed_key_unused.iam_client",
new=iam_client,
),
mock.patch(
"prowler.providers.gcp.services.iam.iam_sa_user_managed_key_unused.iam_sa_user_managed_key_unused.monitoring_client",
new=monitoring_client,
),
):
from prowler.providers.gcp.services.iam.iam_sa_user_managed_key_unused.iam_sa_user_managed_key_unused import (
iam_sa_user_managed_key_unused,
)
from prowler.providers.gcp.services.iam.iam_service import (
Key,
ServiceAccount,
)
iam_client.project_ids = [GCP_PROJECT_ID]
iam_client.region = GCP_US_CENTER1_LOCATION
iam_client.service_accounts = [
ServiceAccount(
name="projects/my-project/serviceAccounts/my-service-account@my-project.iam.gserviceaccount.com",
email="my-service-account@my-project.iam.gserviceaccount.com",
display_name="My service account",
keys=[
Key(
name="90c48f61c65cd56224a12ab18e6ee9ca9c3aee7c",
origin="GOOGLE_PROVIDED",
type="SYSTEM_MANAGED",
valid_after=datetime.strptime("2024-07-10", "%Y-%m-%d"),
valid_before=datetime.strptime("9999-12-31", "%Y-%m-%d"),
),
Key(
name="e5e3800831ac1adc8a5849da7d827b4724b1fce8",
origin="GOOGLE_PROVIDED",
type="USER_MANAGED",
valid_after=datetime.strptime("2024-07-10", "%Y-%m-%d"),
valid_before=datetime.strptime("9999-12-31", "%Y-%m-%d"),
),
Key(
name="f8e4771561be5cda9b1267add7006c5143e3a220",
origin="GOOGLE_PROVIDED",
type="USER_MANAGED",
valid_after=datetime.strptime("2024-07-10", "%Y-%m-%d"),
valid_before=datetime.strptime("9999-12-31", "%Y-%m-%d"),
),
],
project_id=GCP_PROJECT_ID,
)
]
monitoring_client.sa_keys_metrics = set(
["f8e4771561be5cda9b1267add7006c5143e3a220"]
)
check = iam_sa_user_managed_key_unused()
result = check.execute()
assert len(result) == 2
assert result[0].status == "FAIL"
assert (
result[0].status_extended
== f"User-managed key {iam_client.service_accounts[0].keys[1].name} for Service Account {iam_client.service_accounts[0].email} was not used over the last 180 days."
)
assert result[0].resource_id == iam_client.service_accounts[0].keys[1].name
assert result[0].project_id == GCP_PROJECT_ID
assert result[0].location == GCP_US_CENTER1_LOCATION
assert result[0].resource_name == iam_client.service_accounts[0].email
assert result[1].status == "PASS"
assert (
result[1].status_extended
== f"User-managed key {iam_client.service_accounts[0].keys[2].name} for Service Account {iam_client.service_accounts[0].email} was used over the last 180 days."
)
assert result[1].resource_id == iam_client.service_accounts[0].keys[2].name
assert result[1].project_id == GCP_PROJECT_ID
assert result[1].location == GCP_US_CENTER1_LOCATION
assert result[1].resource_name == iam_client.service_accounts[0].email
@@ -41,3 +41,25 @@ class TestMonitoringService:
assert monitoring_client.alert_policies[1].filters == [
'metric.type="compute.googleapis.com/instance/disk/write_bytes_count"'
]
def test_sa_keys_metrics(self):
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,
),
):
monitoring_client = Monitoring(
set_mocked_gcp_provider(project_ids=[GCP_PROJECT_ID])
)
assert monitoring_client.service == "monitoring"
assert monitoring_client.project_ids == [GCP_PROJECT_ID]
assert len(monitoring_client.sa_keys_metrics) == 2
assert "key1" in monitoring_client.sa_keys_metrics
assert "key2" in monitoring_client.sa_keys_metrics
assert "key3" not in monitoring_client.sa_keys_metrics