├── requirements.txt ├── LICENSE ├── .gitignore ├── README.md └── gcp-iam-analyzer.py /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | tqdm -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | Copyright [2022] [Jason Dyke] 6 | 7 | Licensed under the Apache License, Version 2.0 (the "License"); 8 | you may not use this file except in compliance with the License. 9 | You may obtain a copy of the License at 10 | 11 | http://www.apache.org/licenses/LICENSE-2.0 12 | 13 | Unless required by applicable law or agreed to in writing, software 14 | distributed under the License is distributed on an "AS IS" BASIS, 15 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | See the License for the specific language governing permissions and 17 | limitations under the License. 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | 132 | # Script specifics 133 | roles/* -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GCP IAM Analyzer 2 | 3 | This tool is an all-in-one GCP IAM analyzer with helpful functions for working with roles and permissions. 4 | 5 | ## Table of Contents 6 | 7 | - [Features](#features) 8 | - [Role Analysis](#role-analysis) 9 | - [Permissions Analysis](#permissions-analysis) 10 | - [Usage](#usage) 11 | - [Example](#example) 12 | - [Feedback](#feedback) 13 | 14 | ## Features 15 | 16 | There are two main types of features this tool offers: role analysis and permissions analysis. 17 | 18 | ### Role Analysis 19 | 20 | Currently supports up to 2 IAM roles to: 21 | 22 | - Calculate the differences in permissions between the two. (`-d` flag) 23 | - Which permissions the two roles share. (`-s` flag) 24 | - Lists permissions for a given role or list of roles. (supports 1 + N roles). (`-l` flag) 25 | - Or can do all of the above at once. (`-a` flag) 26 | 27 | In order to determine what permissions a role has we need some type of role -> permission lookup. We have a roles database via a different project [gcp_iam_update_bot](https://github.com/jdyke/gcp_iam_update_bot) which keeps an up to date list of all GCP IAM roles and their permissions (refreshes every 12 hours). 28 | 29 | Before any role analysis takes place the script will look for the `roles/` directory and prompt you to download it if it does not exist: 30 | 31 | ```bash 32 | ./gcp-iam-analyzer.py -d vpcaccess.admin vpcaccess.viewer 33 | ERROR:"roles" folder does not exist. This is required for analysis. 34 | Do you want to download the "roles" folder now? y/n 35 | ``` 36 | 37 | You update your local roles database at anytime via `./gcp-iam-analyzer.py -r`. 38 | 39 | ### Permissions Analysis 40 | 41 | - Will calculate which IAM roles have N + 1 IAM permissions. This is useful if you'd like to know which roles share similar permissions. (`-p` flag) 42 | 43 | ## Usage 44 | 45 | ```bash 46 | ./gcp-iam-analyzer.py --help 47 | usage: gcp-iam-analyzer.py [-h] [-d ROLES [ROLES ...]] [-s ROLES [ROLES ...]] [-a ROLES [ROLES ...]] 48 | [-l ROLES [ROLES ...]] [-p PERM [PERM ...]] [-r] 49 | 50 | Compares GCP IAM roles and outputs analysis. 51 | 52 | optional arguments: 53 | -h, --help show this help message and exit 54 | -d ROLES [ROLES ...], --diff ROLES [ROLES ...] 55 | Compares roles and outputs the permissions difference. 56 | -s ROLES [ROLES ...], --shared ROLES [ROLES ...] 57 | Compares roles and outputs the shared permissions. 58 | -a ROLES [ROLES ...], --all ROLES [ROLES ...] 59 | Compares roles and outputs the differences and the shared permissins. 60 | -l ROLES [ROLES ...], --list ROLES [ROLES ...] 61 | Lists permissions for role(s). 62 | -p PERM [PERM ...], --perm PERM [PERM ...] 63 | Lists roles which contain a specific permission. 64 | -r, --refresh Refreshes the local "roles" folder 65 | ``` 66 | 67 | ## Example 68 | 69 | Let's say we have a user in GCP that has the `vpcaccess.admin` role and you want to find out how many permissions they would "lose" if they were assigned the `vpcaccess.viewer` role. 70 | 71 | ```bash 72 | ./gcp-iam-analyzer.py -d vpcaccess.viewer vpcaccess.admin 73 | 74 | Role "vpcaccess.viewer" differences: 75 | 'N/A' 76 | Role "vpcaccess.admin" differences: 77 | 'vpcaccess.connectors.delete' 78 | 'vpcaccess.connectors.create' 79 | 'vpcaccess.connectors.use' 80 | ``` 81 | 82 | The above output shows that by assigning the `vpcaccess.viewer` role and removing the `vpcaccess.admin` role the user would lose: 83 | 84 | ```bash 85 | 'vpcaccess.connectors.create', 86 | 'vpcaccess.connectors.delete', 87 | 'vpcaccess.connectors.use' 88 | ``` 89 | 90 | ## Feedback 91 | 92 | Feel free to open an issue if you encounter a bug or reach out via twitter [@jasonadyke](https://twitter.com/jasonadyke) 93 | -------------------------------------------------------------------------------- /gcp-iam-analyzer.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | import argparse 4 | import logging 5 | import os 6 | import requests 7 | import tarfile 8 | import sys 9 | import shutil 10 | import json 11 | import re 12 | 13 | from pprint import pprint 14 | from tqdm import tqdm 15 | 16 | 17 | def inputs(args): 18 | if args["diff"]: 19 | if len(args["diff"]) != 2: 20 | logging.error("Need 2 roles to compare differences..") 21 | logging.error("Please rerun with 2 roles. Exiting. \n") 22 | sys.exit(1) 23 | else: 24 | logging.info("Diff flag set, will output permissions diff. \n") 25 | diff_roles = args["diff"] 26 | perms_diff(diff_roles) 27 | if args["shared"]: 28 | if len(args["shared"]) != 2: 29 | logging.error("Need 2 roles to compare shared permissions..") 30 | logging.error("Please rerun with 2 roles. Exiting. \n") 31 | sys.exit(1) 32 | else: 33 | logging.info("Shared flag set, will output shared permissions. \n") 34 | shared_roles = args["shared"] 35 | perms_shared(shared_roles) 36 | if args["all"]: 37 | if len(args["all"]) != 2: 38 | logging.error( 39 | "Need 2 roles to compare both different and shared permissions..") 40 | logging.error("Please rerun with 2 roles. Exiting. \n") 41 | sys.exit(1) 42 | else: 43 | logging.info( 44 | "All flag set, will output diff and shared permissions. \n") 45 | all_roles = args["all"] 46 | perms_all(all_roles) 47 | if args["list"]: 48 | logging.info( 49 | "List flag set, will output permissions for supplied role(s). \n") 50 | list_roles = args["list"] 51 | list_perms(list_roles) 52 | if args["perm"]: 53 | logging.info( 54 | "Permission flag set, will output all roles which contain the supplied permission(s). \n") 55 | if len(args["perm"]) == 1: 56 | role_permission = str(args["perm"][0]) 57 | list_roles_for_perm(role_permission) 58 | else: 59 | for permission in args["perm"]: 60 | list_roles_for_perm(permission) 61 | 62 | 63 | def perms_diff(diff_roles): 64 | """ 65 | Takes 2 roles and displays the different permissions contained in each. 66 | """ 67 | 68 | # Currently only supports comparing 2 roles 69 | # Can safely assume only 2 elements in list 70 | role_one = diff_roles[0] 71 | role_two = diff_roles[1] 72 | 73 | # Generate the list of permissions per role 74 | role_one_perms = get_permissions(role_one) 75 | role_two_perms = get_permissions(role_two) 76 | 77 | # Get the diff for role1 78 | role_one_diff = set(role_one_perms).difference(set(role_two_perms)) 79 | print(f"\n # Role \"{role_one}\" differences:") 80 | if not role_one_diff: 81 | role_one_diff = "N/A" 82 | pprint(role_one_diff) 83 | else: 84 | for permission in sorted(role_one_diff): 85 | pprint(permission) 86 | 87 | # Get the diff for role2 88 | role_two_diff = set(role_two_perms).difference(set(role_one_perms)) 89 | print(f"\n # Role \"{role_two}\" differences:") 90 | if not role_two_diff: 91 | role_two_diff = "N/A" 92 | pprint(role_two_diff) 93 | else: 94 | for permission in sorted(role_two_diff): 95 | pprint(permission) 96 | 97 | 98 | def get_permissions(role_name): 99 | """ 100 | Takes a role and finds the permissions it contains 101 | """ 102 | # Create a list of permissions for a given role 103 | try: 104 | with open(f"./roles/{role_name}", "r") as role_file: 105 | try: 106 | role_file = json.load(role_file) 107 | except: 108 | logging.error(f"Problem reading file {role_name}. Is file empty?") 109 | # Some roles do not have this key 110 | if "includedPermissions" in role_file: 111 | role_perms = role_file["includedPermissions"] 112 | else: 113 | role_perms = [] 114 | 115 | return role_perms 116 | 117 | except FileNotFoundError as file_err: 118 | logging.error(f"Role not found. Check your spelling.") 119 | logging.debug(file_err) 120 | sys.exit(1) 121 | 122 | 123 | def perms_shared(shared_roles): 124 | """ 125 | Takes 2 roles and displays the shared permissions contained in each. 126 | """ 127 | 128 | # Currently only supports comparing 2 roles 129 | # Can safely assume only 2 elements in list 130 | role_one = shared_roles[0] 131 | role_two = shared_roles[1] 132 | 133 | # Generate the list of permissions per role 134 | role_one_perms = get_permissions(role_one) 135 | role_two_perms = get_permissions(role_two) 136 | 137 | # Compare the two lists and display similarities 138 | shared_perms = set(role_one_perms) & set(role_two_perms) 139 | number_of_shared_perms = len(shared_perms) 140 | if shared_perms: 141 | print( 142 | f"\n # There are {number_of_shared_perms} shared permissions between {role_one} and {role_two}: \n") 143 | for perms in sorted(shared_perms): 144 | pprint(perms) 145 | else: 146 | print("\n There are no shared permissions.") 147 | 148 | 149 | def perms_all(all_roles): 150 | """ 151 | Compares 2 roles and outputs the differences and the shared permissinos. 152 | """ 153 | logging.info("Finding differences.. \n") 154 | perms_diff(all_roles) 155 | 156 | logging.info("\n Finding shared permissions.. \n") 157 | perms_shared(all_roles) 158 | 159 | 160 | def roles_refresh(): 161 | """ 162 | This function: 163 | - Downloads the most recent GCP IAM roles dataset 164 | - Extracts only the roles data 165 | - Cleans up old unneeded files/directories 166 | """ 167 | 168 | try: 169 | # Get the latest release tag URL 170 | logging.info("Downloading latest GCP IAM roles dataset... \n") 171 | response = requests.get( 172 | "https://api.github.com/repos/jdyke/gcp_iam_update_bot/releases/latest") 173 | 174 | # Construct the tarball download URL 175 | tarball_name = response.json()["tag_name"] 176 | download_url = f"https://github.com/jdyke/gcp_iam_update_bot/archive/refs/tags/{tarball_name}.tar.gz" 177 | 178 | # Download location 179 | target_path = "latest.tar.gz" 180 | 181 | # Download tarball 182 | response = requests.get(download_url, stream=True) 183 | if response.status_code == 200: 184 | with open(target_path, 'wb') as f: 185 | f.write(response.raw.read()) 186 | except: 187 | logging.error("Could not download roles dataset") 188 | raise 189 | 190 | # Extract only "roles/" folder 191 | logging.info("Extracting roles from dataset... \n") 192 | with tarfile.open(target_path) as tar: 193 | def is_within_directory(directory, target): 194 | 195 | abs_directory = os.path.abspath(directory) 196 | abs_target = os.path.abspath(target) 197 | 198 | prefix = os.path.commonprefix([abs_directory, abs_target]) 199 | 200 | return prefix == abs_directory 201 | 202 | def safe_extract(tar, path=".", members=None, *, numeric_owner=False): 203 | 204 | for member in tar.getmembers(): 205 | member_path = os.path.join(path, member.name) 206 | if not is_within_directory(path, member_path): 207 | raise Exception("Attempted Path Traversal in Tar File") 208 | 209 | tar.extractall(path, members) 210 | 211 | 212 | safe_extract(tar, members=members(tar)) 213 | 214 | logging.info("Formatting data and cleaning up unneeded files... \n") 215 | # Move tarball directory to "roles/" 216 | move_dir = "gcp_iam_update_bot-" + tarball_name 217 | move_directory(move_dir) 218 | 219 | # Tell user number of roles found 220 | number_of_roles_extracted = len(os.listdir('./roles')) 221 | print(f"Number of roles found: {number_of_roles_extracted} \n") 222 | 223 | 224 | def list_perms(list_roles): 225 | """ 226 | Lists permissions for each supplied role. 227 | 228 | Args: 229 | list_roles (list): A list of roles 230 | """ 231 | 232 | # For each role in the list 233 | # Find the permissions 234 | for role in list_roles: 235 | perms_list = get_permissions(role) 236 | number_of_perms = len(perms_list) 237 | print(f"# There are {number_of_perms} permissions for {role}:") 238 | for perm in perms_list: 239 | pprint(perm) 240 | 241 | 242 | def list_roles_for_perm(role_permission): 243 | """ 244 | Lists all known GCP IAM roles that contain a specific permission. 245 | 246 | Args: 247 | role_permission (str): A GCP IAM permission. 248 | """ 249 | 250 | # Before performing role analysis validate the permission 251 | # is formatted correctly 252 | validated = permission_validation(role_permission) 253 | 254 | # If permission is properly formatted get list of roles to analyze 255 | if validated: 256 | all_roles_names = get_all_role_names() 257 | else: 258 | logging.error( 259 | "All IAM permissions must be formatted \"service.resource.action\"") 260 | logging.error(f"You entered: {role_permission}") 261 | sys.exit(1) 262 | 263 | # Before finding roles remove special characters other than periods 264 | role_permission = format_permission(role_permission) 265 | 266 | # Empty list which we will add roles with permission to 267 | roles_with_perm = [] 268 | 269 | # Count total number of roles to use in logging 270 | number_of_roles_extracted = len(os.listdir('./roles')) 271 | 272 | # For each role name in our roles/ directory 273 | for role_name in tqdm(all_roles_names, desc=f"Searching {number_of_roles_extracted} roles:", colour="green" ): 274 | # We first get the list of permissions in the role 275 | role_perms = get_permissions(role_name) 276 | # Then we check for the specific permission in the list 277 | if role_permission in role_perms: 278 | roles_with_perm.append(role_name) 279 | 280 | # If there are roles with the specific permission 281 | if roles_with_perm: 282 | roles_with_perm.sort() 283 | number_of_roles_with_perm = len(roles_with_perm) 284 | print(f"\n # There are {number_of_roles_with_perm} roles with the \"{role_permission}\" permission: \n") 285 | for role in roles_with_perm: 286 | pprint(role) 287 | else: 288 | print(f"No roles found with permission \"{role_permission}\"") 289 | print("Check your spelling and capitalization.") 290 | 291 | 292 | def permission_validation(role_permission): 293 | """ 294 | Check the permission for 2 periods which is the IAM permission 295 | format. 296 | 297 | The format should always match "service.resource.action" 298 | ^^ statement is true as of Sept 25, 2022 299 | 300 | Args: 301 | role_permission (str): A GCP IAM permission. 302 | """ 303 | 304 | num_periods = role_permission.count(".") 305 | if num_periods == 2: 306 | return True 307 | 308 | 309 | def format_permission(role_permission): 310 | """ 311 | Look for commas and remove. 312 | This is useful if a user passes in a list of permissions with commas 313 | 314 | Args: 315 | role_permission (str): A GCP IAM permission. 316 | """ 317 | 318 | new_permission = re.sub(r",", "", role_permission) 319 | 320 | return new_permission 321 | 322 | 323 | def get_all_role_names(): 324 | """ 325 | Gets a list of all IAM role names 326 | """ 327 | 328 | try: 329 | role_names = os.listdir("roles/") 330 | except: 331 | logging.error("Could not list the \"roles/\" directory") 332 | raise 333 | 334 | return role_names 335 | 336 | 337 | def move_directory(move_dir): 338 | """ 339 | Moves the tarball directory to "roles/" 340 | """ 341 | # Check if directory already exists 342 | # If so, delete. We are refreshing the data. 343 | roles_dir = os.path.isdir("roles/") 344 | if roles_dir: 345 | shutil.rmtree("roles/") 346 | 347 | # Move directory 348 | directory_to_move = move_dir + "/roles/" 349 | try: 350 | shutil.move(directory_to_move, "./roles/") 351 | except: 352 | "Could not rename tarball directory" 353 | raise 354 | 355 | # Clean tarball and empty dir 356 | cleanup(move_dir) 357 | 358 | 359 | def cleanup(move_dir): 360 | """ 361 | Removes empty or unneeded directories 362 | """ 363 | try: 364 | os.remove("latest.tar.gz") 365 | except: 366 | logging.error("Could not remove tarball.") 367 | 368 | try: 369 | os.rmdir(move_dir) 370 | except: 371 | logging.error("Could not remove empty tarball directory.") 372 | 373 | 374 | def members(tarball): 375 | """ 376 | Returns/yields only files in the "roles/" directory 377 | 378 | "member" is a term tarfile uses for files inside a tarball 379 | """ 380 | l = len("roles/") 381 | for member in tarball.getmembers(): 382 | if "roles/" in member.path: 383 | yield member 384 | 385 | 386 | if __name__ == "__main__": 387 | # Configure logging format 388 | logging.basicConfig(format='%(levelname)s:%(message)s', 389 | level=logging.ERROR) 390 | 391 | # Configure arguments 392 | parser = argparse.ArgumentParser( 393 | description="Compares GCP IAM roles and outputs analysis.") 394 | parser.add_argument("-d", "--diff", nargs='+', metavar="ROLES", 395 | help="Compares roles and outputs the permissions difference.") 396 | parser.add_argument("-s", "--shared", nargs='+', metavar="ROLES", 397 | help="Compares roles and outputs the shared permissions.") 398 | parser.add_argument("-a", "--all", nargs='+', metavar="ROLES", 399 | help="Compares roles and outputs the differences and the shared permissins.") 400 | parser.add_argument("-l", "--list", nargs='+', 401 | metavar="ROLES", help="Lists permissions for role(s).") 402 | parser.add_argument("-p", "--perm", nargs='+', 403 | metavar="PERM", help="Lists roles which contain a specific permission.") 404 | parser.add_argument( 405 | "-r", "--refresh", help="Refreshes the local \"roles\" folder.", action='store_true') 406 | 407 | args = vars(parser.parse_args()) 408 | 409 | # Check if user wants to download or refresh roles folder. 410 | if args["refresh"]: 411 | logging.info( 412 | "Refresh flag set, will refresh local \"roles\" folder and continue..") 413 | roles_refresh() 414 | print("Roles directory updated. \n") 415 | if not args["diff"] and not args["shared"] and not args["all"] and not args["list"]: 416 | print("Exiting - no further action requested.") 417 | sys.exit(0) 418 | 419 | # Require at least one argument 420 | if not args["diff"] and not args["shared"] and not args["all"] and not args["list"] and not args["perm"]: 421 | logging.error("One argument must be supplied.") 422 | sys.exit(0) 423 | 424 | # check if roles folder exists 425 | path = "./roles" 426 | is_folder = os.path.isdir(path) 427 | 428 | if is_folder: 429 | logging.debug("Roles folder exists.. proceeding") 430 | else: 431 | logging.error( 432 | "\"roles\" folder does not exist. This is required for analysis.") 433 | 434 | # Ask user if they want to dl roles folder 435 | refresh = input( 436 | "Do you want to download the \"roles\" folder now? y/N \n") 437 | if refresh == "y": 438 | roles_refresh() 439 | elif refresh == "N": 440 | logging.info( 441 | "\"roles\" folder is required for analysis. Please execute with -r flag.") 442 | else: 443 | logging.error( 444 | f"Invalid or no input found. Value entered: \"{refresh}\"") 445 | 446 | inputs(args) 447 | --------------------------------------------------------------------------------