├── .github └── workflows │ ├── publish.yaml │ └── unittest.yaml ├── .gitignore ├── LICENSE.md ├── Makefile ├── README.md ├── pyproject.toml ├── requirements.txt ├── src ├── __init__.py ├── analyzer.py ├── gke.py ├── gsa.py └── reporter.py └── tests ├── __init__.py ├── test_gkeworkload.py └── test_gsaproject.py /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | deploy: 9 | 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v3 14 | - name: Set up Python 15 | uses: actions/setup-python@v4 16 | with: 17 | python-version: '3.10' 18 | - name: Install dependencies 19 | run: | 20 | python -m pip install --upgrade pip 21 | pip install setuptools wheel twine 22 | - name: Build and publish 23 | env: 24 | TWINE_USERNAME: ${{ secrets.TWINE_USERNAME }} 25 | TWINE_PASSWORD: ${{ secrets.TWINE_PASSWORD }} 26 | run: | 27 | make build 28 | make pypi-upload 29 | -------------------------------------------------------------------------------- /.github/workflows/unittest.yaml: -------------------------------------------------------------------------------- 1 | name: Python unittest 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | python-version: ["3.7", "3.8", "3.9", "3.10"] 12 | 13 | steps: 14 | - uses: actions/checkout@v3 15 | - name: Set up Python ${{ matrix.python-version }} 16 | uses: actions/setup-python@v4 17 | with: 18 | python-version: ${{ matrix.python-version }} 19 | cache: 'pip' 20 | - name: Install dependencies 21 | run: | 22 | pip install -r requirements.txt 23 | - name: Run unittest 24 | run: | 25 | make unittest 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | wi_analyzer.egg-info 3 | dist 4 | venv 5 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright 2022 DoiT International 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | venv-create: 2 | pip install virtualenv 3 | python3 -m venv venv 4 | 5 | venv-activate: 6 | @echo '. venv/bin/activate' 7 | 8 | venv-deactive: 9 | @echo 'deactivate' 10 | 11 | clean: 12 | rm -rf dist 13 | 14 | build: clean 15 | pip install build 16 | python3 -m build 17 | 18 | install: build 19 | pip install dist/*.whl 20 | 21 | pypi-upload: 22 | pip install twine 23 | twine upload dist/* 24 | 25 | unittest: 26 | python3 -m unittest discover 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## GKE Workload Identity Analyzer 2 | 3 | This script takes a Pod name (running in the current context) and performs checks to ensure that [Workload Identity](https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity) is properly configured. 4 | 5 | ### Performed checks 6 | 7 | - Workload Identity enabled on the GKE cluster 8 | - Pod has `.spec.serviceAccountName` configured 9 | - KSA (configured in previous step) exists 10 | - KSA is annotated correctly with a GSA 11 | - GSA (configured in previous step) exists in the project 12 | - KSA has `roles/iam.workloadIdentityUser` on the GSA 13 | - GSA IAM roles in the project 14 | 15 | [![Supported Versions](https://img.shields.io/pypi/pyversions/wi-analyzer.svg)](https://pypi.org/project/wi-analyzer) 16 | 17 | ## Prerequisites 18 | 19 | - [`gcloud` cli](https://cloud.google.com/sdk/docs/install) installed and configured 20 | - Application Default Credentials generated [using gcloud](https://cloud.google.com/sdk/gcloud/reference/auth/application-default/login) 21 | - [`kubectl`](https://cloud.google.com/kubernetes-engine/docs/how-to/cluster-access-for-kubectl) installed and configured with cluster access 22 | - [current kubectl context](https://kubernetes.io/docs/tasks/access-application-cluster/configure-access-multiple-clusters/#define-clusters-users-and-contexts) pointing to the relevant cluster 23 | - python 3 and [pip](https://pypi.org/project/pip/) installed 24 | - if running from source, python requirements installed: `pip install -r requirements.txt` 25 | 26 | ## Installation 27 | 28 | This package is published to [PyPI](https://pypi.org/project/wi-analyzer/) and can be installed using `pip`: 29 | 30 | ```bash 31 | pip install wi-analyzer 32 | ``` 33 | 34 | ### Necessary project access 35 | 36 | The script can be run by a user with the [`Viewer`](https://cloud.google.com/iam/docs/understanding-roles#basic-definitions) role in the project. 37 | 38 | Alternatively, the user will need enough GKE cluster access to read Pods and ServiceAccounts, plus the following IAM permissions: 39 | 40 | - container.clusters.get 41 | - iam.serviceAccounts.get 42 | - iam.serviceAccounts.getIamPolicy 43 | - resourcemanager.projects.getIamPolicy 44 | 45 | If the GSA is in a different GCP project than the GKE cluster, you'll need the last 3 permissions on that project instead. 46 | 47 | ## Using the tool 48 | 49 | ```bash 50 | $ wi-analyzer --help 51 | usage: wi-analyzer [-h] [-n NAMESPACE] [-d] pod 52 | 53 | GKE Workload Identity Analyzer 54 | 55 | positional arguments: 56 | pod Kubernetes Pod name to check 57 | 58 | options: 59 | -h, --help show this help message and exit 60 | -n NAMESPACE, --namespace NAMESPACE 61 | Kubernetes Namespace to run in 62 | -p PROJECT, --project PROJECT 63 | GCP Project holding the cluster 64 | -l LOCATION, --location LOCATION 65 | The GCP location of the cluster 66 | -c CLUSTER, --cluster CLUSTER 67 | The name of the cluster 68 | -d, --debug Enable debug logging 69 | ``` 70 | 71 | Configure your current context to point at the cluster where the workload is running. 72 | Either configure the relevant [namespace for the current context](https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/#setting-the-namespace-preference) or pass the namespace name using the `-n` flag. 73 | 74 | Pass a pod name to check - it can be part of a Deployment, Job, StatefulSet, etc, but it has to be running already. 75 | 76 | ## TODO 77 | 78 | - Support [Fleet Workload Identity](https://cloud.google.com/anthos/fleet-management/docs/use-workload-identity) (GKE WI for Anthos) 79 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = 'setuptools.build_meta' 3 | requires = ['setuptools', 'setuptools-scm'] 4 | 5 | [project] 6 | name = 'wi-analyzer' 7 | description = 'GKE Workload Identity Analyzer' 8 | authors = [ 9 | {name = 'Eyal Zekaria', email='eyal.z@doit-intl.com'}, 10 | ] 11 | readme = 'README.md' 12 | requires-python = '>=3.7' 13 | classifiers = [ 14 | 'Programming Language :: Python :: 3 :: Only', 15 | ] 16 | keywords = ['gke', 'workload-identity'] 17 | dynamic = ['version', 'dependencies'] 18 | 19 | [project.scripts] 20 | wi-analyzer = 'analyzer:main' 21 | 22 | [tool.setuptools.dynamic] 23 | dependencies = {file = 'requirements.txt'} 24 | 25 | [tool.setuptools_scm] 26 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | google-api-python-client==2.58.0 2 | kubernetes==24.2.0 3 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/doitintl/workload-identity-analyzer/9dcc3c21ed502f776e9547c8049a817ed2ae00c8/src/__init__.py -------------------------------------------------------------------------------- /src/analyzer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import argparse 5 | import logging 6 | from gke import GkeWorkload 7 | from gsa import GsaProject 8 | from reporter import Reporter 9 | 10 | def parse_args(): 11 | parser = argparse.ArgumentParser( 12 | description='GKE Workload Identity Analyzer') 13 | parser.add_argument('pod', help='Kubernetes Pod name to check', type=str) 14 | parser.add_argument('-n', '--namespace', 15 | help='Kubernetes Namespace to run in', type=str) 16 | parser.add_argument('-p', '--project', 17 | help='GCP Project holding the cluster', type=str) 18 | parser.add_argument('-l', '--location', 19 | help='The GCP location of the cluster', type=str) 20 | parser.add_argument('-c', '--cluster', 21 | help='The name of the cluster', type=str) 22 | parser.add_argument('-d', '--debug', help='Enable debug logging', 23 | action='store_true') 24 | args = parser.parse_args() 25 | inclusive_group = [args.project, args.location, args.cluster] 26 | if (all(v is not None for v in inclusive_group) or 27 | all(v is None for v in inclusive_group)): 28 | return args 29 | parser.error( 30 | 'Either set all, or none of: "PROJECT", "LOCATION", "CLUSTER"') 31 | 32 | 33 | def init_logger(args): 34 | level = logging.DEBUG if os.environ.get( 35 | 'DEBUG', args.debug) else logging.INFO 36 | logging.basicConfig(level=level, format='%(message)s') 37 | return logging.getLogger(name='analyzer') 38 | 39 | 40 | 41 | def main(): 42 | args = parse_args() 43 | logger = init_logger(args) 44 | logger.debug(args) 45 | reporter = Reporter() 46 | 47 | gke = GkeWorkload(args, reporter) 48 | gke.check_cluster() 49 | gke.check_pod() 50 | gke.check_node() 51 | gke.check_node_labels() 52 | gke.check_ksa() 53 | gke.check_ksa_annotation() 54 | 55 | project = GsaProject( 56 | reporter, gke.get_gsa(), 57 | gke.get_ksa_string(), 58 | gke.get_project(), 59 | gke.get_check_status()) 60 | project.check_gsa() 61 | project.check_gsa_enabled() 62 | project.check_gsa_iam_policy() 63 | project.check_gsa_ksa_workload_identity_user() 64 | 65 | reporter.print_report(gke, project) 66 | 67 | 68 | if __name__ == '__main__': 69 | main() 70 | -------------------------------------------------------------------------------- /src/gke.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import googleapiclient.discovery 3 | from kubernetes import client, config 4 | from reporter import Reporter 5 | 6 | logger = logging.getLogger() 7 | 8 | class GkeWorkload(object): 9 | """This class represents a workload running in GKE""" 10 | node_label = 'iam.gke.io/gke-metadata-server-enabled' 11 | ksa_annotation = 'iam.gke.io/gcp-service-account' 12 | 13 | def __init__(self, args, reporter): 14 | super(GkeWorkload, self).__init__() 15 | self.args = args 16 | self.reporter = reporter 17 | self.check_failed = False 18 | self.cluster = None 19 | self.cluster_name = None 20 | self.gsa = None 21 | self.ksa_name = None 22 | self.namespace = None 23 | self.node = None 24 | self.node_name = None 25 | self.project = None 26 | config.load_kube_config() 27 | self.set_gke_info() 28 | self.set_namespace() 29 | self.v1 = client.CoreV1Api() 30 | 31 | @Reporter.check_decorator('GCP project and GKE info ' 32 | 'determined from current context') 33 | def set_gke_info(self): 34 | if self.args.project: 35 | self.project = self.args.project 36 | self.location = self.args.location 37 | self.cluster_name = 'projects/%s/locations/%s/clusters/%s' % ( 38 | self.args.project, self.args.location, self.args.cluster) 39 | return 40 | try: 41 | c = config.list_kube_config_contexts( 42 | )[1]['context']['cluster'].split('_') 43 | _, project, location, cluster = c 44 | self.project = project 45 | self.cluster_name = 'projects/%s/locations/%s/clusters/%s' % ( 46 | project, location, cluster) 47 | except: 48 | logger.error('Failed to get cluster info from current context, ' 49 | 'or it was not passed as arguments\n') 50 | self.check_failed = True 51 | 52 | @Reporter.check_decorator('Namespace passed as argument,' 53 | 'or determined from current context') 54 | def set_namespace(self): 55 | if self.args.namespace: 56 | self.namespace = self.args.namespace 57 | return 58 | try: 59 | self.namespace = config.list_kube_config_contexts()[ 60 | 1]['context']['namespace'] 61 | except: 62 | logger.error('Failed to get NS from current context\n') 63 | self.check_failed = True 64 | 65 | def get_gsa(self): 66 | return self.gsa 67 | 68 | def get_project(self): 69 | return self.project 70 | 71 | def get_ksa_string(self): 72 | return 'serviceAccount:%s.svc.id.goog[%s/%s]' % ( 73 | self.project, self.namespace, self.ksa_name) 74 | 75 | def get_check_status(self): 76 | return self.check_failed 77 | 78 | def print_info(self): 79 | if self.cluster_name: 80 | logger.info('Cluster: "%s"' % self.cluster_name) 81 | logger.info('Workload: "%s/%s" running on Node: "%s"' % 82 | (self.namespace, self.args.pod, self.node_name)) 83 | logger.info('KSA name: "%s"' % self.ksa_name) 84 | else: 85 | logger.info('Cluster info could not be determined, ' 86 | 'is your current context set correctly?') 87 | 88 | @Reporter.check_decorator('Workload Identity enabled on GKE Cluster') 89 | def check_cluster(self): 90 | service = googleapiclient.discovery.build( 91 | 'container', 'v1', cache_discovery=False) 92 | c = service.projects().locations().clusters().get( 93 | name=self.cluster_name).execute() 94 | try: 95 | wi_pool = c['workloadIdentityConfig']['workloadPool'] 96 | except KeyError: 97 | self.check_failed = True 98 | 99 | @Reporter.check_decorator('Pod found in current context') 100 | def check_pod(self): 101 | try: 102 | pod = self.v1.read_namespaced_pod(self.args.pod, self.namespace) 103 | self.ksa_name = pod.spec.service_account_name 104 | self.node_name = pod.spec.node_name 105 | except client.exceptions.ApiException: 106 | logger.error('Failed to find pod %s/%s in current context\n' % 107 | (self.namespace, self.args.pod)) 108 | self.check_failed = True 109 | 110 | @Reporter.check_decorator('GKE Node found in the cluster') 111 | def check_node(self): 112 | logger.debug('Pod is running on node %s' % self.node_name) 113 | try: 114 | n = self.v1.read_node(self.node_name) 115 | self.node = n 116 | except client.exceptions.ApiException: 117 | logger.error('Failed to get Node %s from the API\n' % 118 | self.node_name) 119 | self.check_failed = True 120 | 121 | @Reporter.check_decorator('Workload Identity enabled on Node Pool') 122 | def check_node_labels(self): 123 | try: 124 | if not bool(self.node.metadata.labels[self.node_label]): 125 | self.check_failed = True 126 | except KeyError: 127 | self.check_failed = True 128 | 129 | @Reporter.check_decorator('KSA found in the cluster') 130 | def check_ksa(self): 131 | logger.debug('Pod spec is using KSA "%s"' % self.ksa_name) 132 | try: 133 | self.ksa = self.v1.read_namespaced_service_account( 134 | self.ksa_name, self.namespace) 135 | except client.exceptions.ApiException: 136 | logger.error('Failed to get KSA %s from the API\n' % self.ksa_name) 137 | self.check_failed = True 138 | 139 | @Reporter.check_decorator('KSA Workload Identity annotation set correctly') 140 | def check_ksa_annotation(self): 141 | try: 142 | gsa = self.ksa.metadata.annotations[self.ksa_annotation] 143 | self.gsa = gsa 144 | except (TypeError, KeyError): 145 | self.check_failed = True 146 | -------------------------------------------------------------------------------- /src/gsa.py: -------------------------------------------------------------------------------- 1 | import re 2 | import logging 3 | import googleapiclient.discovery 4 | from googleapiclient.errors import HttpError 5 | from reporter import Reporter 6 | 7 | logger = logging.getLogger() 8 | 9 | 10 | def format_wi_user(user): 11 | try: 12 | match = re.search(r'\[([\w-]+/[\w-]+)\]$', user) 13 | except TypeError: 14 | match = None 15 | if match: 16 | namespace, ksa = match.group(1).split('/') 17 | return '%s (Namespace: %s, KSA: %s)' % (user, namespace, ksa) 18 | else: 19 | return '%s (Namespace and KSA could not be determined - wrong binding?)' % user 20 | 21 | 22 | class GsaProject(object): 23 | """This class represents a GCP Project in which a GSA resides""" 24 | 25 | def __init__(self, reporter, gsa, ksa_string, project, check_failed): 26 | super(GsaProject, self).__init__() 27 | self.check_failed = check_failed 28 | self.reporter = reporter 29 | self.gsa = gsa 30 | self.ksa_string = ksa_string 31 | self.project = self.get_project(project) 32 | self.gsa_link = 'projects/%s/serviceAccounts/%s' % ( 33 | self.project, self.gsa) 34 | self.gsa_status = None 35 | self.wi_users = [] 36 | self.iam = googleapiclient.discovery.build( 37 | 'iam', 'v1', cache_discovery=False) 38 | 39 | def get_project(self, project): 40 | # will try to extract the Project ID from GSA email 41 | pattern = r'(?:.+@)(([a-z]|-){6,30})(?:\.iam\.gserviceaccount\.com$)' 42 | try: 43 | p = re.search(pattern, self.gsa) 44 | except TypeError: 45 | p = None 46 | if p: 47 | gsa_project = p.groups()[0] 48 | logger.debug('GSA is in a different GCP project: %s' % gsa_project) 49 | return gsa_project 50 | else: 51 | return project 52 | 53 | def print_info(self): 54 | if self.gsa: 55 | logger.info('Google Service Account: "%s"' % self.gsa_link) 56 | logger.info('Has the following Workload Identity Users:\n%s' % 57 | '\n'.join(map(format_wi_user, self.wi_users))) 58 | else: 59 | logger.info('Google Service Account information could ' 60 | 'not be determined, fix previous issues') 61 | 62 | @Reporter.check_decorator('GSA found in GCP project') 63 | def check_gsa(self): 64 | try: 65 | self.gsa_status = self.iam.projects().serviceAccounts().get( 66 | name=self.gsa_link).execute() 67 | except HttpError: 68 | logger.error('Failed to get GSA %s\n' % self.gsa_link) 69 | self.check_failed = True 70 | 71 | @Reporter.check_decorator('GSA is enabled') 72 | def check_gsa_enabled(self): 73 | if self.gsa_status.get('disabled'): 74 | self.check_failed = True 75 | 76 | @Reporter.check_decorator('GSA has Workload Identity users configured') 77 | def check_gsa_iam_policy(self): 78 | gsa_policy = self.iam.projects().serviceAccounts().getIamPolicy( 79 | resource=self.gsa_link).execute() 80 | try: 81 | self.wi_users = [ 82 | b['members'] for b in gsa_policy['bindings'] 83 | if b['role'] == 'roles/iam.workloadIdentityUser'][0] 84 | except (IndexError, KeyError): 85 | self.check_failed = True 86 | 87 | @Reporter.check_decorator('GSA does not have KSA ' 88 | 'as a Workload Identity user') 89 | def check_gsa_ksa_workload_identity_user(self): 90 | if self.ksa_string not in self.wi_users: 91 | self.check_failed = True 92 | 93 | def list_gsa_project_roles(self): 94 | service = googleapiclient.discovery.build( 95 | 'cloudresourcemanager', 'v1', cache_discovery=False 96 | ) 97 | project_policy = service.projects( 98 | ).getIamPolicy(resource=self.project).execute() 99 | gsa_roles = [b['role'] for b in project_policy['bindings'] 100 | if 'serviceAccount:%s' % self.gsa in b['members']] 101 | logger.debug(project_policy) 102 | if gsa_roles: 103 | logger.info('GSA: "%s" has the following roles ' 104 | 'in project "%s":\n%s' % 105 | (self.gsa, self.project, '\n'.join(gsa_roles))) 106 | else: 107 | logger.error('GSA: "%s" has no permissions in project "%s"\n' % ( 108 | self.gsa, self.project)) 109 | -------------------------------------------------------------------------------- /src/reporter.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from functools import wraps 3 | 4 | logger = logging.getLogger() 5 | 6 | class Reporter(object): 7 | """This class represents an analysis reporter""" 8 | 9 | def __init__(self): 10 | super(Reporter, self).__init__() 11 | self.entries = [] 12 | 13 | def check_decorator(msg): 14 | def check_wrapper(func): 15 | @wraps(func) 16 | def report(*args, **kwargs): 17 | if args[0].check_failed: 18 | skipped = True 19 | else: 20 | skipped = False 21 | func(*args, **kwargs) 22 | args[0].reporter.add_entry(args[0].check_failed, skipped, msg) 23 | return report 24 | return check_wrapper 25 | 26 | def add_entry(self, failed, skipped, msg): 27 | if skipped: 28 | status = '-' 29 | elif failed: 30 | status = 'X' 31 | else: 32 | status = 'V' 33 | self.entries.append({ 34 | 'status': status, 35 | 'message': msg 36 | }) 37 | 38 | def is_passing(self): 39 | return all([e['status'] == 'V' for e in self.entries]) 40 | 41 | def print_report(self, gke, project): 42 | logger.info('Check results') 43 | logger.info('---------------------------') 44 | logger.info('V=Passed, X=Failed, -=Skipped\n') 45 | for e in self.entries: 46 | logger.info('[%s] %s' % (e['status'], e['message'])) 47 | logger.info('') 48 | logger.info('GKE cluster info') 49 | logger.info('---------------------------') 50 | gke.print_info() 51 | logger.info('') 52 | logger.info('Google Service Account info') 53 | logger.info('---------------------------') 54 | project.print_info() 55 | logger.info('') 56 | if self.is_passing(): 57 | project.list_gsa_project_roles() 58 | logger.info('Workload Identity configured properly - check ' 59 | 'if any IAM roles are missing from the list above') 60 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | PROJECT_PATH = os.getcwd() 4 | SOURCE_PATH = os.path.join( 5 | PROJECT_PATH, 'src' 6 | ) 7 | sys.path.append(SOURCE_PATH) 8 | -------------------------------------------------------------------------------- /tests/test_gkeworkload.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import MagicMock, patch 3 | from gke import GkeWorkload 4 | from analyzer import parse_args, init_logger 5 | from reporter import Reporter 6 | 7 | 8 | class GkeWorkloadTestCase(unittest.TestCase): 9 | PROJECT_NAME = 'test-project' 10 | LOCATION = 'test-location' 11 | CLUSTER = 'test-cluster' 12 | NAMESPACE = 'test-namespace' 13 | KSA = 'test-ksa' 14 | CONTEXT = {'context': 15 | {'cluster': 16 | 'gke_%s_%s_%s' % (PROJECT_NAME, LOCATION, CLUSTER), 17 | 'namespace': NAMESPACE}} 18 | 19 | @classmethod 20 | def setUpClass(self): 21 | args = parse_args() 22 | init_logger(args) 23 | with patch('kubernetes.config.load_kube_config'): 24 | with patch('kubernetes.config.list_kube_config_contexts', 25 | return_value=({}, self.CONTEXT)): 26 | self.gkeWorkload = GkeWorkload(args, Reporter()) 27 | 28 | def setUp(self): 29 | self.gkeWorkload.check_failed = False 30 | 31 | def test_cluster_name(self): 32 | self.assertEqual(self.gkeWorkload.cluster_name, 33 | 'projects/%s/locations/%s/clusters/%s' % ( 34 | self.PROJECT_NAME, self.LOCATION, self.CLUSTER)) 35 | 36 | def test_namespace_name(self): 37 | self.assertEqual(self.gkeWorkload.namespace, self.NAMESPACE) 38 | 39 | @patch('googleapiclient.discovery.build') 40 | def test_check_cluster(self, mock_build): 41 | self.gkeWorkload.check_cluster() 42 | self.assertFalse(self.gkeWorkload.check_failed) 43 | 44 | def test_check_pod(self): 45 | pod = MagicMock() 46 | pod.spec.service_account_name = self.KSA 47 | pod.spec.node_name = 'test-node' 48 | with patch.object(self.gkeWorkload.v1, 'read_namespaced_pod', 49 | return_value=pod): 50 | self.gkeWorkload.check_pod() 51 | self.assertFalse(self.gkeWorkload.check_failed) 52 | self.assertEqual(self.gkeWorkload.ksa_name, self.KSA) 53 | self.assertEqual(self.gkeWorkload.node_name, 'test-node') 54 | 55 | def test_check_node(self): 56 | node = MagicMock() 57 | node.metadata.labels = { 58 | self.gkeWorkload.node_label: 'test' 59 | } 60 | with patch.object(self.gkeWorkload.v1, 'read_node', return_value=node): 61 | self.gkeWorkload.check_node() 62 | self.assertFalse(self.gkeWorkload.check_failed) 63 | self.gkeWorkload.check_node_labels() 64 | self.assertFalse(self.gkeWorkload.check_failed) 65 | 66 | def test_check_node_fail(self): 67 | node = MagicMock() 68 | node.metadata.labels = { 69 | 'wrong-label': 'test' 70 | } 71 | with patch.object(self.gkeWorkload.v1, 'read_node', return_value=node): 72 | self.gkeWorkload.check_node() 73 | self.assertFalse(self.gkeWorkload.check_failed) 74 | self.gkeWorkload.check_node_labels() 75 | self.assertTrue(self.gkeWorkload.check_failed) 76 | 77 | def test_check_ksa(self): 78 | ksa = MagicMock() 79 | ksa.metadata.annotations = { 80 | self.gkeWorkload.ksa_annotation: 'test' 81 | } 82 | with patch.object(self.gkeWorkload.v1, 83 | 'read_namespaced_service_account', 84 | return_value=ksa): 85 | self.gkeWorkload.check_ksa() 86 | self.assertFalse(self.gkeWorkload.check_failed) 87 | self.gkeWorkload.check_ksa_annotation() 88 | self.assertFalse(self.gkeWorkload.check_failed) 89 | 90 | def test_check_ksa_fail(self): 91 | ksa = MagicMock() 92 | ksa.metadata.annotations = { 93 | 'wrong-annotation': 'test' 94 | } 95 | with patch.object(self.gkeWorkload.v1, 96 | 'read_namespaced_service_account', 97 | return_value=ksa): 98 | self.gkeWorkload.check_ksa() 99 | self.assertFalse(self.gkeWorkload.check_failed) 100 | self.gkeWorkload.check_ksa_annotation() 101 | self.assertTrue(self.gkeWorkload.check_failed) 102 | 103 | 104 | if __name__ == '__main__': 105 | unittest.main() 106 | -------------------------------------------------------------------------------- /tests/test_gsaproject.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from unittest.mock import MagicMock, patch 3 | from gsa import GsaProject 4 | from analyzer import parse_args, init_logger 5 | from reporter import Reporter 6 | 7 | 8 | @patch('googleapiclient.discovery.build') 9 | class GsaProjectTestCase(unittest.TestCase): 10 | @classmethod 11 | def setUpClass(self): 12 | args = parse_args() 13 | init_logger(args) 14 | 15 | def init_gsa_project(self, gsa=None, ksa=None, project=None): 16 | return GsaProject(Reporter(), gsa, ksa, project, False) 17 | 18 | def test_get_project(self, mock_build): 19 | # Project extracted from GSA email 20 | gsaProject = self.init_gsa_project(project='test-project', 21 | gsa='foo@test-gsa-project' 22 | '.iam.gserviceaccount.com') 23 | self.assertEqual(gsaProject.project, 'test-gsa-project') 24 | # Project falls back to GKE project 25 | gsaProject = self.init_gsa_project(project='test-project', 26 | gsa='invalid@gsa.email') 27 | self.assertEqual(gsaProject.project, 'test-project') 28 | 29 | def test_check_gsa_link(self, mock_build): 30 | gsaProject = self.init_gsa_project( 31 | gsa='foo@test-project.iam.gserviceaccount.com') 32 | self.assertEqual(gsaProject.gsa_link, 33 | 'projects/test-project/serviceAccounts/' 34 | 'foo@test-project.iam.gserviceaccount.com') 35 | 36 | def test_check_gsa_enabled(self, mock_build): 37 | gsaProject = self.init_gsa_project() 38 | mock_build.return_value.projects.return_value.serviceAccounts\ 39 | .return_value.get.return_value.execute.return_value = { 40 | 'disabled': False} 41 | gsaProject.check_gsa() 42 | self.assertFalse(gsaProject.check_failed) 43 | gsaProject.check_gsa_enabled() 44 | self.assertFalse(gsaProject.check_failed) 45 | 46 | def test_check_gsa_disabled(self, mock_build): 47 | gsaProject = self.init_gsa_project() 48 | mock_build.return_value.projects.return_value.serviceAccounts\ 49 | .return_value.get.return_value.execute.return_value = { 50 | 'disabled': True} 51 | gsaProject.check_gsa() 52 | self.assertFalse(gsaProject.check_failed) 53 | gsaProject.check_gsa_enabled() 54 | self.assertTrue(gsaProject.check_failed) 55 | 56 | def test_check_gsa_iam_policy_success(self, mock_build): 57 | gsaProject = self.init_gsa_project() 58 | mock_build.return_value.projects.return_value.serviceAccounts\ 59 | .return_value.getIamPolicy.return_value.execute.return_value = { 60 | 'bindings': [{'role': 'roles/iam.workloadIdentityUser', 61 | 'members': ['serviceAccount:test.svc' 62 | '.id.goog[test/test]']}]} 63 | gsaProject.check_gsa_iam_policy() 64 | self.assertFalse(gsaProject.check_failed) 65 | 66 | def test_check_gsa_iam_policy_fail(self, mock_build): 67 | gsaProject = self.init_gsa_project() 68 | mock_build.return_value.projects.return_value.serviceAccounts\ 69 | .return_value.getIamPolicy.return_value.execute.return_value = {} 70 | gsaProject.check_gsa_iam_policy() 71 | self.assertTrue(gsaProject.check_failed) 72 | 73 | def test_check_gsa_ksa_workload_identity_user_success(self, mock_build): 74 | gsaProject = self.init_gsa_project(ksa='serviceAccount:test.svc' 75 | '.id.goog[test/test]') 76 | mock_build.return_value.projects.return_value.serviceAccounts\ 77 | .return_value.getIamPolicy.return_value.execute.return_value = { 78 | 'bindings': [{'role': 'roles/iam.workloadIdentityUser', 79 | 'members': ['serviceAccount:test.svc' 80 | '.id.goog[test/test]']}]} 81 | gsaProject.check_gsa_iam_policy() 82 | self.assertFalse(gsaProject.check_failed) 83 | gsaProject.check_gsa_ksa_workload_identity_user() 84 | self.assertFalse(gsaProject.check_failed) 85 | 86 | def test_check_gsa_ksa_workload_identity_user_fail(self, mock_build): 87 | gsaProject = self.init_gsa_project(ksa='serviceAccount:test.svc' 88 | '.id.goog[test/test]') 89 | mock_build.return_value.projects.return_value.serviceAccounts\ 90 | .return_value.getIamPolicy.return_value.execute.return_value = { 91 | 'bindings': [{'role': 'roles/iam.workloadIdentityUser', 92 | 'members': ['serviceAccount:test.svc' 93 | '.id.goog[another/another]']}]} 94 | gsaProject.check_gsa_iam_policy() 95 | self.assertFalse(gsaProject.check_failed) 96 | gsaProject.check_gsa_ksa_workload_identity_user() 97 | self.assertTrue(gsaProject.check_failed) 98 | 99 | def test_list_gsa_project_roles(self, mock_build): 100 | gsaProject = self.init_gsa_project( 101 | gsa='testMember', project='test-project') 102 | mock_build.return_value.projects.return_value.getIamPolicy\ 103 | .return_value.execute.return_value = { 104 | 'bindings': [ 105 | {'role': 'roles/viewer', 106 | 'members': ['serviceAccount:testMember']}, 107 | {'role': 'roles/editor', 108 | 'members': ['serviceAccount:testMember']} 109 | ] 110 | } 111 | with self.assertLogs() as cm: 112 | gsaProject.list_gsa_project_roles() 113 | self.assertEqual(cm.output, [ 114 | 'INFO:root:GSA: "testMember" has the following ' 115 | 'roles in project "test-project":' 116 | '\nroles/viewer\nroles/editor']) 117 | 118 | 119 | if __name__ == '__main__': 120 | unittest.main() 121 | --------------------------------------------------------------------------------