From cf848753555124b42247c92eee403af32a78865d Mon Sep 17 00:00:00 2001 From: Sergio Garcia <38561120+sergargar@users.noreply.github.com> Date: Wed, 26 Jun 2024 09:31:47 -0400 Subject: [PATCH] feat(gcp): add service account impersonation (#4291) --- docs/tutorials/gcp/authentication.md | 10 ++++ poetry.lock | 6 +- prowler/providers/gcp/gcp_provider.py | 60 ++++++++++++++----- .../providers/gcp/lib/arguments/arguments.py | 6 ++ tests/lib/cli/parser_test.py | 8 +++ tests/providers/gcp/gcp_provider_test.py | 3 + 6 files changed, 76 insertions(+), 17 deletions(-) diff --git a/docs/tutorials/gcp/authentication.md b/docs/tutorials/gcp/authentication.md index e9587fa1e2..d339348cab 100644 --- a/docs/tutorials/gcp/authentication.md +++ b/docs/tutorials/gcp/authentication.md @@ -25,6 +25,16 @@ Prowler will follow the same credentials search as [Google authentication librar Those credentials must be associated to a user or service account with proper permissions to do all checks. To make sure, add the `Viewer` role to the member associated with the credentials. +## Impersonate Service Account + +If you want to impersonate a GCP service account, you can use the `--impersonate-service-account` argument: + +```console +prowler gcp --impersonate-service-account +``` + +This argument will use the default credentials to impersonate the service account provided. + # GCP Service APIs Prowler will use the Google Cloud APIs to get the information needed to perform the checks. Make sure that the following APIs are enabled in the project: diff --git a/poetry.lock b/poetry.lock index 8745fcfc30..f9c2b060ed 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1606,13 +1606,13 @@ uritemplate = ">=3.0.1,<5" [[package]] name = "google-auth" -version = "2.29.0" +version = "2.30.0" description = "Google Authentication Library" optional = false python-versions = ">=3.7" files = [ - {file = "google-auth-2.29.0.tar.gz", hash = "sha256:672dff332d073227550ffc7457868ac4218d6c500b155fe6cc17d2b13602c360"}, - {file = "google_auth-2.29.0-py2.py3-none-any.whl", hash = "sha256:d452ad095688cd52bae0ad6fafe027f6a6d6f560e810fec20914e17a09526415"}, + {file = "google-auth-2.30.0.tar.gz", hash = "sha256:ab630a1320f6720909ad76a7dbdb6841cdf5c66b328d690027e4867bdfb16688"}, + {file = "google_auth-2.30.0-py2.py3-none-any.whl", hash = "sha256:8df7da660f62757388b8a7f249df13549b3373f24388cb5d2f1dd91cc18180b5"}, ] [package.dependencies] diff --git a/prowler/providers/gcp/gcp_provider.py b/prowler/providers/gcp/gcp_provider.py index ad34bff0d9..e0db4c608e 100644 --- a/prowler/providers/gcp/gcp_provider.py +++ b/prowler/providers/gcp/gcp_provider.py @@ -3,7 +3,8 @@ import re import sys from colorama import Fore, Style -from google import auth +from google.auth import default, impersonated_credentials +from google.auth.transport.requests import Request from google.oauth2.credentials import Credentials from googleapiclient import discovery from googleapiclient.errors import HttpError @@ -38,9 +39,12 @@ class GcpProvider(Provider): input_project_ids = arguments.project_id excluded_project_ids = arguments.excluded_project_id credentials_file = arguments.credentials_file + self._impersonated_service_account = arguments.impersonate_service_account list_project_ids = arguments.list_project_id - self._session = self.setup_session(credentials_file) + self._session = self.setup_session( + credentials_file, self._impersonated_service_account + ) self._project_ids = [] self._projects = {} @@ -120,6 +124,10 @@ class GcpProvider(Provider): def projects(self): return self._projects + @property + def impersonated_service_account(self): + return self._impersonated_service_account + @property def project_ids(self): return self._project_ids @@ -168,14 +176,39 @@ class GcpProvider(Provider): # "partition": "identity.partition", } - def setup_session(self, credentials_file): + def setup_session(self, credentials_file: str, service_account: str) -> Credentials: + """ + Setup the GCP session with the provided credentials file or service account to impersonate + Args: + credentials_file: str + service_account: str + Returns: + Credentials object + """ try: + scopes = ["https://www.googleapis.com/auth/cloud-platform"] + if credentials_file: + logger.info(f"Using credentials file: {credentials_file}") self.__set_gcp_creds_env_var__(credentials_file) - credentials, _ = auth.default( - scopes=["https://www.googleapis.com/auth/cloud-platform"] - ) + # Get default credentials + credentials, _ = default(scopes=scopes) + + # Refresh the credentials to ensure they are valid + credentials.refresh(Request()) + + logger.info(f"Initial credentials: {credentials}") + + if service_account: + # Create the impersonated credentials + credentials = impersonated_credentials.Credentials( + source_credentials=credentials, + target_principal=service_account, + target_scopes=scopes, + ) + logger.info(f"Impersonated credentials: {credentials}") + return credentials except Exception as error: logger.critical( @@ -197,6 +230,10 @@ class GcpProvider(Provider): f"GCP Account: {Fore.YELLOW}{self.identity.profile}{Style.RESET_ALL}", f"GCP Project IDs: {Fore.YELLOW}{', '.join(self.project_ids)}{Style.RESET_ALL}", ] + if self.impersonated_service_account: + report_lines.append( + f"Impersonated Service Account: {Fore.YELLOW}{self.impersonated_service_account}{Style.RESET_ALL}" + ) if self.excluded_project_ids: report_lines.append( f"Excluded GCP Project IDs: {Fore.YELLOW}{', '.join(self.excluded_project_ids)}{Style.RESET_ALL}" @@ -259,14 +296,9 @@ class GcpProvider(Provider): f"{http_error.__class__.__name__}[{http_error.__traceback__.tb_lineno}]: {http_error}" ) except Exception as error: - if error.__class__.__name__ == "RefreshError": - logger.critical( - "Google Cloud SDK has not been authenticated or the credentials have expired. Authenticate by running 'gcloud auth application-default login' then retry." - ) - else: - logger.critical( - f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" - ) + logger.critical( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) sys.exit(1) finally: return projects diff --git a/prowler/providers/gcp/lib/arguments/arguments.py b/prowler/providers/gcp/lib/arguments/arguments.py index 86463f310b..e9239cb3ab 100644 --- a/prowler/providers/gcp/lib/arguments/arguments.py +++ b/prowler/providers/gcp/lib/arguments/arguments.py @@ -12,6 +12,12 @@ def init_parser(self): metavar="FILE_PATH", help="Authenticate using a Google Service Account Application Credentials JSON file", ) + gcp_auth_modes_group.add_argument( + "--impersonate-service-account", + nargs="?", + metavar="SERVICE_ACCOUNT", + help="Impersonate a Google Service Account", + ) # Projects gcp_projects_subparser = gcp_parser.add_argument_group("Projects") gcp_projects_subparser.add_argument( diff --git a/tests/lib/cli/parser_test.py b/tests/lib/cli/parser_test.py index 0b39e158aa..98123651fb 100644 --- a/tests/lib/cli/parser_test.py +++ b/tests/lib/cli/parser_test.py @@ -1229,6 +1229,14 @@ class Test_Parser: assert parsed.provider == "gcp" assert parsed.list_project_id + def test_parser_gcp_impersonate_service_account(self): + argument = "--impersonate-service-account" + service_account = "test@test.iam.gserviceaccount.com" + command = [prowler_command, "gcp", argument, service_account] + parsed = self.parser.parse(command) + assert parsed.provider == "gcp" + assert parsed.impersonate_service_account == service_account + def test_parser_kubernetes_auth_kubeconfig_file(self): argument = "--kubeconfig-file" file = "config" diff --git a/tests/providers/gcp/gcp_provider_test.py b/tests/providers/gcp/gcp_provider_test.py index 8bd667b9e9..f4cc9958ab 100644 --- a/tests/providers/gcp/gcp_provider_test.py +++ b/tests/providers/gcp/gcp_provider_test.py @@ -20,6 +20,7 @@ class TestGCPProvider: arguments.excluded_project_id = [] arguments.list_project_id = False arguments.credentials_file = "" + arguments.impersonate_service_account = "" arguments.config_file = default_config_file_path arguments.fixer_config = default_fixer_config_file_path @@ -56,6 +57,7 @@ class TestGCPProvider: arguments.excluded_project_id = [] arguments.list_project_id = False arguments.credentials_file = "" + arguments.impersonate_service_account = "" arguments.config_file = default_config_file_path arguments.fixer_config = default_fixer_config_file_path @@ -128,6 +130,7 @@ class TestGCPProvider: arguments.excluded_project_id = [] arguments.list_project_id = False arguments.credentials_file = "" + arguments.impersonate_service_account = "" arguments.config_file = default_config_file_path arguments.fixer_config = default_fixer_config_file_path