├── .gitignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── account_metadata_lib └── account_metadata.py ├── iam-access-key-report.py ├── iam_helper_lib └── iam_helper.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | .venv/* 2 | **/__pycache__/* 3 | *.csv 4 | *.json -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | ## Code of Conduct 2 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 3 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 4 | opensource-codeofconduct@amazon.com with any additional questions or comments. 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | Thank you for your interest in contributing to our project. Whether it's a bug report, new feature, correction, or additional 4 | documentation, we greatly value feedback and contributions from our community. 5 | 6 | Please read through this document before submitting any issues or pull requests to ensure we have all the necessary 7 | information to effectively respond to your bug report or contribution. 8 | 9 | 10 | ## Reporting Bugs/Feature Requests 11 | 12 | We welcome you to use the GitHub issue tracker to report bugs or suggest features. 13 | 14 | When filing an issue, please check existing open, or recently closed, issues to make sure somebody else hasn't already 15 | reported the issue. Please try to include as much information as you can. Details like these are incredibly useful: 16 | 17 | * A reproducible test case or series of steps 18 | * The version of our code being used 19 | * Any modifications you've made relevant to the bug 20 | * Anything unusual about your environment or deployment 21 | 22 | 23 | ## Contributing via Pull Requests 24 | Contributions via pull requests are much appreciated. Before sending us a pull request, please ensure that: 25 | 26 | 1. You are working against the latest source on the *main* branch. 27 | 2. You check existing open, and recently merged, pull requests to make sure someone else hasn't addressed the problem already. 28 | 3. You open an issue to discuss any significant work - we would hate for your time to be wasted. 29 | 30 | To send us a pull request, please: 31 | 32 | 1. Fork the repository. 33 | 2. Modify the source; please focus on the specific change you are contributing. If you also reformat all the code, it will be hard for us to focus on your change. 34 | 3. Ensure local tests pass. 35 | 4. Commit to your fork using clear commit messages. 36 | 5. Send us a pull request, answering any default questions in the pull request interface. 37 | 6. Pay attention to any automated CI failures reported in the pull request, and stay involved in the conversation. 38 | 39 | GitHub provides additional document on [forking a repository](https://help.github.com/articles/fork-a-repo/) and 40 | [creating a pull request](https://help.github.com/articles/creating-a-pull-request/). 41 | 42 | 43 | ## Finding contributions to work on 44 | Looking at the existing issues is a great way to find something to contribute on. As our projects, by default, use the default GitHub issue labels (enhancement/bug/duplicate/help wanted/invalid/question/wontfix), looking at any 'help wanted' issues is a great place to start. 45 | 46 | 47 | ## Code of Conduct 48 | This project has adopted the [Amazon Open Source Code of Conduct](https://aws.github.io/code-of-conduct). 49 | For more information see the [Code of Conduct FAQ](https://aws.github.io/code-of-conduct-faq) or contact 50 | opensource-codeofconduct@amazon.com with any additional questions or comments. 51 | 52 | 53 | ## Security issue notifications 54 | If you discover a potential security issue in this project we ask that you notify AWS/Amazon Security via our [vulnerability reporting page](http://aws.amazon.com/security/vulnerability-reporting/). Please do **not** create a public github issue. 55 | 56 | 57 | ## Licensing 58 | 59 | See the [LICENSE](LICENSE) file for our project's licensing. We will ask you to confirm the licensing of your contribution. 60 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT No Attribution 2 | 3 | Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 12 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 13 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 14 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 15 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 16 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 17 | 18 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IAM Access Key Report 2 | This is a simple tool written in python that will enumerate data about all active IAM access keys across an AWS Organization and will enrich each key with account tag information. 3 | 4 | The output of the tool is a CSV file that can then be filtered based on the tag information associated with the keys. 5 | 6 | This allow us to focus on access keys that are used in things like 'Environment:Production' accounts or in accounts that are labelled with 'DataClassification:Restricted'. 7 | 8 | In addition, the tool performs some basic security checks of the IAM policies of the user accounts the access keys are attached to. It will tell you if a key has administrative permissions or 9 | is able to read your data in Amazon S3, Amazon RDS, and Amazon DynamoDB. 10 | 11 | ## Solution architecture and design 12 | The tool has been designed to retrieve account tags from an AWS Organization and expects to have those permissions through local credentials or being passed a profile. 13 | For each account it finds, the tool then tries to assume a role into it to extract an IAM Credential report and analyse IAM policies. 14 | 15 | It can be run in one of three ways. 16 | 17 | ### 1. End-to-end 18 | This uses local credentials or a profile that can get account information from AWS Organizations AND can assume a role into each member account. This is the default behaviour. 19 | ``` 20 | $ python3 ./iam-access-key-report.py -o output-file.csv [-p PROFILE] 21 | ``` 22 | 23 | ### 2. Using separate credentials for AWS Organizations access and assuming roles 24 | This breaks up the process into two steps: 25 | 1. Use one set of credentials or profile to get account tags and output this to a file 26 | ``` 27 | $ python3 ./iam-access-key-report.py -s my-tags.json -p PROFILE_TO_ACCESS_ORGANIZATIONS 28 | ``` 29 | 2. Use a separate set of credentials or profile to access each member account while providing the account tags in a file 30 | ``` 31 | $ python3 ./iam-access-key-report.py -l my-tags.json -p PROFILE_TO_ACCESS_MEMBER_ACCOUNTS -o output.csv 32 | ``` 33 | 34 | ### 3. Supplying your own account metadata in CSV format 35 | If you have existing account metadata in CSV format, you can import it using the `-i` parameter. You will need to make sure that the column storing AWS account numbers is titled 'aws_account_id' otherwise the import will fail. 36 | ``` 37 | $ python3 ./iam-access-key-report.py -i account-metadata.csv -p PROFILE_TO_ACCESS_MEMBER_ACCOUNTS -o output.csv 38 | ``` 39 | 40 | ## Permissions required 41 | You will need the following permissions in the AWS Organization management account or an account that is a delegated administrator for an AWS service: 42 | - organizations:ListTagsForResources 43 | - organizations:ListOrganizationalUnitsForParents 44 | - organizations:ListAccountsForParents 45 | - organizations:DescribeOrganizationalUnits 46 | - organizations:ListRoots 47 | 48 | To access each member account you will need to provide the name of a role that the tool can use to assume to retrieve access key information. 49 | The role in each account must have the following permissions: 50 | - iam:CreateCredentialReport 51 | - iam:GetCredentialReport 52 | 53 | ## Usage 54 | Basic usage of the script: 55 | `$ iam-access-key-report.py [-h] [-s METADATA_SAVE | -l METADATA_LOAD | -i METADATA_CSV_INPUT] [-r ROLE] [-p PROFILE] [-o CSV_OUTPUT]` 56 | 57 | `-h` 58 | shows a help messages and exits 59 | 60 | `-s metadata_file` 61 | will get all account tags, write them to a file, and then exit. This is if you just want the tag information or if you dont want to have to keep pulling it each time the tool is run 62 | 63 | `-l metadata_file` 64 | you can load tag information that has previously been extracted by using (-s) 65 | 66 | `-i metadata_file.csv` 67 | ability to import your own account metadata. One column must be labeled 'aws_account_id' which lists account Ids. 68 | 69 | `-r role` 70 | provide a role name that can be used to assume into each account. the role will need to already exist or you will need to create it. 71 | 72 | `p profile` 73 | the name of a profile configured through the AWS CLI (if you dont specify a profile, the script will use whatever local credentials it can find) 74 | 75 | `-o csv_output` 76 | the output file generated in CSV format 77 | 78 | ## How to analyze IAM access key reports 79 | 80 | The CSV reports created by this solution can be ingested by AWS services such as Amazon Athena, Amazon Quicksight, or third-party products from our AWS Partners. 81 | 82 | ## Security 83 | 84 | See [CONTRIBUTING](CONTRIBUTING.md#security-issue-notifications) for more information. 85 | 86 | ## License 87 | 88 | This library is licensed under the MIT-0 License. See the LICENSE file. 89 | 90 | -------------------------------------------------------------------------------- /account_metadata_lib/account_metadata.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | import logging 5 | import boto3 6 | import json 7 | 8 | from botocore.exceptions import ClientError 9 | 10 | def get_account_metadata(session=None): 11 | 12 | # if we don't get a session, create one 13 | if session is None: 14 | session = boto3.Session() 15 | 16 | organizations = session.client('organizations') 17 | 18 | def get_org_root(): 19 | response = organizations.list_roots() 20 | roots = response.get("Roots", None) 21 | 22 | if not roots: 23 | return None 24 | 25 | return roots[0] 26 | 27 | def get_tags(id): 28 | paginator = organizations.get_paginator("list_tags_for_resource") 29 | responses = paginator.paginate(ResourceId=id) 30 | 31 | tags = {} 32 | for response in responses: 33 | for tag in response.get("Tags", []): 34 | tags[tag["Key"]] = tag["Value"] 35 | 36 | return tags 37 | 38 | def get_child_orgunits(parent_id): 39 | paginator = organizations.get_paginator("list_organizational_units_for_parent") 40 | responses = paginator.paginate(ParentId=parent_id) 41 | 42 | orgunits = [] 43 | for response in responses: 44 | orgunits += response.get("OrganizationalUnits", []) 45 | 46 | return orgunits 47 | 48 | def get_child_accounts(parent_id): 49 | paginator = organizations.get_paginator("list_accounts_for_parent") 50 | responses = paginator.paginate(ParentId=parent_id) 51 | 52 | accounts = [] 53 | for response in responses: 54 | accounts += response.get("Accounts", []) 55 | 56 | return accounts 57 | 58 | accounts_list = {} 59 | accounts_list['Accounts'] = [] 60 | 61 | def walk_org(orgunit_id, depth): 62 | child_accounts = get_child_accounts(orgunit_id) 63 | 64 | for account in child_accounts: 65 | child_account_id = account["Id"] 66 | tags = get_tags(child_account_id) 67 | 68 | account_info = {} 69 | account_info['Id'] = child_account_id 70 | account_info['OU'] = depth 71 | account_info['Name'] = account['Name'] 72 | account_info['Tags'] = tags 73 | logging.info("Found account: "+json.dumps(account_info)) 74 | accounts_list['Accounts'].append(account_info) 75 | 76 | child_orgunits = get_child_orgunits(orgunit_id) 77 | for orgunit in child_orgunits: 78 | child_orgunit_id = orgunit["Id"] 79 | 80 | # Lookup orgunit details 81 | response = organizations.describe_organizational_unit(OrganizationalUnitId=child_orgunit_id) 82 | name = response["OrganizationalUnit"]["Name"] 83 | tags = get_tags(child_orgunit_id) 84 | 85 | # Walk rest of org 86 | walk_org(child_orgunit_id, depth+',OU='+name) 87 | 88 | org_root = get_org_root() 89 | org_root_id = org_root.get("Id") 90 | walk_org(org_root_id, "OU=root") 91 | 92 | return accounts_list 93 | -------------------------------------------------------------------------------- /iam-access-key-report.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | import boto3 5 | import logging 6 | import pandas as pd 7 | import argparse 8 | import sys 9 | import json 10 | 11 | from iam_helper_lib import iam_helper 12 | from account_metadata_lib import account_metadata 13 | from botocore.exceptions import ClientError 14 | 15 | # stop traceback when we can't assume into an account 16 | sys.tracebacklimit = 0 17 | 18 | # set up logging 19 | logger = logging.getLogger(__name__) 20 | logging.basicConfig( 21 | format='%(asctime)s %(message)s', 22 | level=logging.INFO, 23 | datefmt='%Y-%m-%d %H:%M:%S' 24 | ) 25 | 26 | def main(): 27 | 28 | parser = argparse.ArgumentParser(description="Simple script used to enumerate IAM access key information and enrich it with account tags from AWS Organizations") 29 | group = parser.add_mutually_exclusive_group() 30 | group.add_argument("-s", "--metadata-save", type=str, help="Save account metadata to this file and do nothing else") 31 | group.add_argument("-l", "--metadata-load", type=str, help="Load a metadata file created using (-s)") 32 | group.add_argument("-i", "--metadata-csv-input", type=str, help="Import your own account metadata in CSV format. AWS account numbers must be in column titled 'aws_account_id'") 33 | parser.add_argument("-r", "--role", type=str, help="Name of an IAM role to assume into member accounts") 34 | parser.add_argument("-p", "--profile", type=str, help="AWS profile used to get account data from AWS Organizations") 35 | parser.add_argument("-o", "--csv-output", type=str, help="CSV output filename") 36 | args = parser.parse_args() 37 | 38 | if args.csv_output and args.metadata_save: 39 | print("Can't use -o with -s.") 40 | exit() 41 | 42 | # empty dataframe for final report 43 | df_final_report = pd.DataFrame() 44 | 45 | # create session using existing profile 46 | admin_session = boto3.Session() 47 | 48 | # use a profile if we have one 49 | if args.profile: 50 | admin_session = boto3.Session(profile_name=args.profile) 51 | 52 | # load our metadata from a file 53 | if args.metadata_load: 54 | logging.info("---- Loading account metadata from "+args.metadata_load+" ----") 55 | f = open(args.metadata_load) 56 | account_data = json.load(f) 57 | f.close() 58 | 59 | df_account_data = pd.json_normalize(account_data['Accounts']) 60 | 61 | elif args.metadata_csv_input: 62 | logging.info("---- Loading custom CSV account metadata from "+args.metadata_csv_input+" ----") 63 | df_account_data = pd.read_csv(args.metadata_csv_input) 64 | 65 | # check if it has a column titled 'aws_account_id' 66 | if 'aws_account_id' not in df_account_data.columns: 67 | logger.exception(args.metadata_csv_input+" doesn't contain a column called 'aws_account_id'") 68 | exit() 69 | 70 | df_account_data.rename(columns={'aws_account_id': 'Id'}, inplace=True) 71 | 72 | else: 73 | # otherwise enumerate accounts from AWS Organizations 74 | logging.info("---- Getting account metadata ----") 75 | account_data = account_metadata.get_account_metadata(admin_session) 76 | logging.info("---- Finished getting account metadata ----") 77 | df_account_data = pd.json_normalize(account_data['Accounts']) 78 | 79 | if args.metadata_save: 80 | # write metadata to disk 81 | json_object = json.dumps(account_data, indent=4) 82 | logger.info("---- Writing account metadata to "+args.metadata_save+" file ----") 83 | with open(args.metadata_save, "w") as outfile: 84 | outfile.write(json_object) 85 | 86 | exit() 87 | 88 | # create sts client 89 | sts = admin_session.client("sts") 90 | 91 | # get credential report for each account 92 | logging.info("---- Attempting to use "+args.role+" role to assume into member accounts ----") 93 | for account in df_account_data['Id']: 94 | if (account.isnumeric() and len(account) == 12): 95 | try: 96 | response = sts.assume_role( 97 | RoleArn="arn:aws:iam::"+account+":role/"+args.role, 98 | RoleSessionName="learnaws-test-session" 99 | ) 100 | 101 | assumed_session = boto3.Session(aws_access_key_id=response['Credentials']['AccessKeyId'], 102 | aws_secret_access_key=response['Credentials']['SecretAccessKey'], 103 | aws_session_token=response['Credentials']['SessionToken']) 104 | 105 | # get list of users in account who have active keys 106 | df_users = iam_helper.get_active_access_keys(assumed_session) 107 | 108 | if df_users.empty: 109 | logging.info("["+account+"]: No active access keys") 110 | else: 111 | # assume all users have low privileges until we discover them 112 | df_users['HighPrivilege'] = False 113 | 114 | for username in df_users['user']: 115 | 116 | policies = [] 117 | user = {} 118 | user['UserName'] = username 119 | dangerous_policies = ['Administrator', 'FullAccess', 'PowerUser'] 120 | 121 | output = iam_helper.get_managed_policies(assumed_session, user) 122 | for policy in output['ManagedPolicies']: 123 | if any([x in policy['Name'] for x in dangerous_policies]): 124 | logging.info("["+account+"]: Danger! - "+user['UserName']+" has policy:"+policy['Name']) 125 | df_users.loc[df_users.user == user['UserName'], 'HighPrivilege'] = True 126 | policies.append(policy['Name']) 127 | else: 128 | logging.debug("["+account+"]: Not dangerous - "+user['UserName']+" has policy:"+policy['Name']) 129 | df_users.loc[df_users.user == user['UserName'], 'policies'] = str(policies) 130 | 131 | # add account number to make joining easy 132 | df_users['AccountNo'] = account 133 | df_keys_metadata = pd.merge(df_account_data, df_users, left_on='Id', right_on='AccountNo') 134 | 135 | # append it to the final df 136 | df_final_report = pd.concat([df_final_report, df_keys_metadata]) 137 | 138 | except ClientError as error: 139 | logger.exception("["+account+"]: Couldn't Assume Role - arn:aws:iam::"+account+":role/"+args.role) 140 | pass 141 | else: 142 | logger.exception("["+account+"]: is not a valid AWS account Id") 143 | 144 | # reset the index and output to csv 145 | df_final_report = df_final_report.reset_index() 146 | del df_final_report['index'] 147 | logger.info("---- Writing CSV output to "+args.csv_output+" ----") 148 | df_final_report.to_csv(args.csv_output, index=False) 149 | 150 | if __name__ == '__main__': 151 | main() -------------------------------------------------------------------------------- /iam_helper_lib/iam_helper.py: -------------------------------------------------------------------------------- 1 | # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. 2 | # SPDX-License-Identifier: MIT-0 3 | 4 | import logging 5 | import boto3 6 | import pandas as pd 7 | from io import StringIO 8 | 9 | from botocore.exceptions import ClientError 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | def get_user_policies(user_arn): 14 | pass 15 | 16 | # enumerates managed policies and appends to user object 17 | def get_managed_policies(session, user): 18 | iam = session.client("iam") 19 | user['ManagedPolicies'] = [] 20 | 21 | #managed_policies_output = {'policies': []}; 22 | 23 | paginator = iam.get_paginator('list_attached_user_policies') 24 | page_iterator = paginator.paginate(UserName=user['UserName']) 25 | for page in page_iterator: 26 | # get policy versions 27 | for policy in page['AttachedPolicies']: 28 | policy_details = {} 29 | policy_details['Name'] = policy['PolicyName'] 30 | policy_details['PolicyArn'] = policy['PolicyArn'] 31 | policy_info = iam.get_policy(PolicyArn=policy['PolicyArn']) 32 | raw_policy = iam.get_policy_version(PolicyArn=policy['PolicyArn'], VersionId=policy_info['Policy']['DefaultVersionId']) 33 | policy_details['RawPolicy'] = raw_policy['PolicyVersion'] 34 | user['ManagedPolicies'].append(policy_details) 35 | 36 | return user 37 | 38 | 39 | 40 | def get_group(): 41 | pass 42 | 43 | 44 | def generate_credential_report(session): 45 | """ 46 | Starts generation of a credentials report about the current account. After 47 | calling this function to generate the report, call get_credential_report 48 | to get the latest report. A new report can be generated a minimum of four hours 49 | after the last one was generated. 50 | """ 51 | try: 52 | iam = session.client("iam") 53 | response = iam.generate_credential_report() 54 | logger.info("Generating credentials report for your account. " 55 | "Current state is %s.", response['State']) 56 | except ClientError: 57 | logger.exception("Couldn't generate a credentials report for your account.") 58 | raise 59 | else: 60 | return response 61 | 62 | def get_credential_report(session): 63 | """ 64 | Gets the most recently generated credentials report about the current account. 65 | :return: The credentials report. 66 | """ 67 | try: 68 | iam = session.client("iam") 69 | response = iam.get_credential_report() 70 | logger.debug(response['Content']) 71 | except ClientError: 72 | logger.exception("Couldn't get credentials report.") 73 | raise 74 | else: 75 | return response['Content'] 76 | 77 | def get_active_access_keys(session): 78 | 79 | # create the credential report if doesn't exist 80 | generate_credential_report(session) 81 | 82 | # pull the report 83 | report = get_credential_report(session) 84 | 85 | # load it into a df 86 | csvStringIO = StringIO(report.decode("utf-8")) 87 | df = pd.read_csv(csvStringIO, sep=",") 88 | 89 | # check for active access keys 90 | df = df.query("access_key_2_active | access_key_1_active") 91 | 92 | return df 93 | 94 | def get_group_policies(session, user): 95 | 96 | client = session.client("iam") 97 | 98 | user['Groups'] = [] 99 | user['Policies'] = [] 100 | try: 101 | policies = [] 102 | 103 | ## Get groups that the user is in 104 | try: 105 | res = client.list_groups_for_user( 106 | UserName=user['UserName'] 107 | ) 108 | user['Groups'] = res['Groups'] 109 | print(res) 110 | while 'IsTruncated' in res and res['IsTruncated'] is True: 111 | res = client.list_groups_for_user( 112 | UserName=user['UserName'], 113 | Marker=res['Marker'] 114 | ) 115 | user['Groups'] += res['Groups'] 116 | except Exception as e: 117 | print('List groups for user failed: {}'.format(e)) 118 | user['PermissionsConfirmed'] = False 119 | 120 | ## Get inline and attached group policies 121 | for group in user['Groups']: 122 | group['Policies'] = [] 123 | ## Get inline group policies 124 | try: 125 | res = client.list_group_policies( 126 | GroupName=group['GroupName'] 127 | ) 128 | policies = res['PolicyNames'] 129 | while 'IsTruncated' in res and res['IsTruncated'] is True: 130 | res = client.list_group_policies( 131 | GroupName=group['GroupName'], 132 | Marker=res['Marker'] 133 | ) 134 | policies += res['PolicyNames'] 135 | except Exception as e: 136 | print('List group policies failed: {}'.format(e)) 137 | user['PermissionsConfirmed'] = False 138 | # Get document for each inline policy 139 | for policy in policies: 140 | print("hi") 141 | group['Policies'].append({ # Add policies to list of policies for this group 142 | 'PolicyName': policy 143 | }) 144 | try: 145 | document = client.get_group_policy( 146 | GroupName=group['GroupName'], 147 | PolicyName=policy 148 | )['PolicyDocument'] 149 | except Exception as e: 150 | print('Get group policy failed: {}'.format(e)) 151 | user['PermissionsConfirmed'] = False 152 | user = parse_document(document, user) 153 | 154 | ## Get attached group policies 155 | attached_policies = [] 156 | try: 157 | res = client.list_attached_group_policies( 158 | GroupName=group['GroupName'] 159 | ) 160 | attached_policies = res['AttachedPolicies'] 161 | while 'IsTruncated' in res and res['IsTruncated'] is True: 162 | res = client.list_attached_group_policies( 163 | GroupName=group['GroupName'], 164 | Marker=res['Marker'] 165 | ) 166 | attached_policies += res['AttachedPolicies'] 167 | group['Policies'] += attached_policies 168 | print("here") 169 | except Exception as e: 170 | print('List attached group policies failed: {}'.format(e)) 171 | user['PermissionsConfirmed'] = False 172 | 173 | user = parse_attached_policies(client, attached_policies, user) 174 | except Exception as e: 175 | print('Error, skipping user {}:\n{}'.format(user['UserName'], e)) 176 | 177 | return user 178 | 179 | # Pull permissions from each policy document 180 | def parse_attached_policies(client, attached_policies, user): 181 | for policy in attached_policies: 182 | document = get_attached_policy(client, policy['PolicyArn']) 183 | if document is False: 184 | user['PermissionsConfirmed'] = False 185 | else: 186 | print("nothing to see") 187 | user = parse_document(document, user) 188 | return user 189 | 190 | # Get the policy document of an attached policy 191 | def get_attached_policy(client, policy_arn): 192 | try: 193 | policy = client.get_policy( 194 | PolicyArn=policy_arn 195 | )['Policy'] 196 | version = policy['DefaultVersionId'] 197 | can_get = True 198 | except Exception as e: 199 | print('Get policy failed: {}'.format(e)) 200 | return False 201 | 202 | try: 203 | if can_get is True: 204 | document = client.get_policy_version( 205 | PolicyArn=policy_arn, 206 | VersionId=version 207 | )['PolicyVersion']['Document'] 208 | return document 209 | except Exception as e: 210 | print('Get policy version failed: {}'.format(e)) 211 | return False 212 | 213 | def parse_document(document, user): 214 | if type(document['Statement']) is dict: 215 | document['Statement'] = [document['Statement']] 216 | for statement in document['Statement']: 217 | if statement['Effect'] == 'Allow': 218 | if 'Action' in statement and type(statement['Action']) is list: # Check if the action is a single action (str) or multiple (list) 219 | statement['Action'] = list(set(statement['Action'])) # Remove duplicates to stop the circular reference JSON error 220 | for action in statement['Action']: 221 | if action in user['Permissions']['Allow']: 222 | if type(statement['Resource']) is list: 223 | user['Permissions']['Allow'][action] += statement['Resource'] 224 | else: 225 | user['Permissions']['Allow'][action].append(statement['Resource']) 226 | else: 227 | if type(statement['Resource']) is list: 228 | user['Permissions']['Allow'][action] = statement['Resource'] 229 | else: 230 | user['Permissions']['Allow'][action] = [statement['Resource']] 231 | user['Permissions']['Allow'][action] = list(set(user['Permissions']['Allow'][action])) # Remove duplicate resources 232 | elif 'Action' in statement and type(statement['Action']) is str: 233 | if statement['Action'] in user['Permissions']['Allow']: 234 | if type(statement['Resource']) is list: 235 | user['Permissions']['Allow'][statement['Action']] += statement['Resource'] 236 | else: 237 | user['Permissions']['Allow'][statement['Action']].append(statement['Resource']) 238 | else: 239 | if type(statement['Resource']) is list: 240 | user['Permissions']['Allow'][statement['Action']] = statement['Resource'] 241 | else: 242 | user['Permissions']['Allow'][statement['Action']] = [statement['Resource']] # Make sure that resources are always arrays 243 | user['Permissions']['Allow'][statement['Action']] = list(set(user['Permissions']['Allow'][statement['Action']])) # Remove duplicate resources 244 | if 'NotAction' in statement and type(statement['NotAction']) is list: # NotAction is reverse, so allowing a NotAction is denying that action basically 245 | statement['NotAction'] = list(set(statement['NotAction'])) # Remove duplicates to stop the circular reference JSON error 246 | for not_action in statement['NotAction']: 247 | if not_action in user['Permissions']['Deny']: 248 | if type(statement['Resource']) is list: 249 | user['Permissions']['Deny'][not_action] += statement['Resource'] 250 | else: 251 | user['Permissions']['Deny'][not_action].append(statement['Resource']) 252 | else: 253 | if type(statement['Resource']) is list: 254 | user['Permissions']['Deny'][not_action] = statement['Resource'] 255 | else: 256 | user['Permissions']['Deny'][not_action] = [statement['Resource']] 257 | user['Permissions']['Deny'][not_action] = list(set(user['Permissions']['Deny'][not_action])) # Remove duplicate resources 258 | elif 'NotAction' in statement and type(statement['NotAction']) is str: 259 | if statement['NotAction'] in user['Permissions']['Deny']: 260 | if type(statement['Resource']) is list: 261 | user['Permissions']['Deny'][statement['NotAction']] += statement['Resource'] 262 | else: 263 | user['Permissions']['Deny'][statement['NotAction']].append(statement['Resource']) 264 | else: 265 | if type(statement['Resource']) is list: 266 | user['Permissions']['Deny'][statement['NotAction']] = statement['Resource'] 267 | else: 268 | user['Permissions']['Deny'][statement['NotAction']] = [statement['Resource']] # Make sure that resources are always arrays 269 | user['Permissions']['Deny'][statement['NotAction']] = list(set(user['Permissions']['Deny'][statement['NotAction']])) # Remove duplicate resources 270 | if statement['Effect'] == 'Deny': 271 | if 'Action' in statement and type(statement['Action']) is list: 272 | statement['Action'] = list(set(statement['Action'])) # Remove duplicates to stop the circular reference JSON error 273 | for action in statement['Action']: 274 | if action in user['Permissions']['Deny']: 275 | if type(statement['Resource']) is list: 276 | user['Permissions']['Deny'][action] += statement['Resource'] 277 | else: 278 | user['Permissions']['Deny'][action].append(statement['Resource']) 279 | else: 280 | if type(statement['Resource']) is list: 281 | user['Permissions']['Deny'][action] = statement['Resource'] 282 | else: 283 | user['Permissions']['Deny'][action] = [statement['Resource']] 284 | user['Permissions']['Deny'][action] = list(set(user['Permissions']['Deny'][action])) # Remove duplicate resources 285 | elif 'Action' in statement and type(statement['Action']) is str: 286 | if statement['Action'] in user['Permissions']['Deny']: 287 | if type(statement['Resource']) is list: 288 | user['Permissions']['Deny'][statement['Action']] += statement['Resource'] 289 | else: 290 | user['Permissions']['Deny'][statement['Action']].append(statement['Resource']) 291 | else: 292 | if type(statement['Resource']) is list: 293 | user['Permissions']['Deny'][statement['Action']] = statement['Resource'] 294 | else: 295 | user['Permissions']['Deny'][statement['Action']] = [statement['Resource']] # Make sure that resources are always arrays 296 | user['Permissions']['Deny'][statement['Action']] = list(set(user['Permissions']['Deny'][statement['Action']])) # Remove duplicate resources 297 | if 'NotAction' in statement and type(statement['NotAction']) is list: # NotAction is reverse, so allowing a NotAction is denying that action basically 298 | statement['NotAction'] = list(set(statement['NotAction'])) # Remove duplicates to stop the circular reference JSON error 299 | for not_action in statement['NotAction']: 300 | if not_action in user['Permissions']['Allow']: 301 | if type(statement['Resource']) is list: 302 | user['Permissions']['Allow'][not_action] += statement['Resource'] 303 | else: 304 | user['Permissions']['Allow'][not_action].append(statement['Resource']) 305 | else: 306 | if type(statement['Resource']) is list: 307 | user['Permissions']['Allow'][not_action] = statement['Resource'] 308 | else: 309 | user['Permissions']['Allow'][not_action] = [statement['Resource']] 310 | user['Permissions']['Allow'][not_action] = list(set(user['Permissions']['Allow'][not_action])) # Remove duplicate resources 311 | elif 'NotAction' in statement and type(statement['NotAction']) is str: 312 | if statement['NotAction'] in user['Permissions']['Allow']: 313 | if type(statement['Resource']) is list: 314 | user['Permissions']['Allow'][statement['NotAction']] += statement['Resource'] 315 | else: 316 | user['Permissions']['Allow'][statement['NotAction']].append(statement['Resource']) 317 | else: 318 | if type(statement['Resource']) is list: 319 | user['Permissions']['Allow'][statement['NotAction']] = statement['Resource'] 320 | else: 321 | user['Permissions']['Allow'][statement['NotAction']] = [statement['Resource']] # Make sure that resources are always arrays 322 | user['Permissions']['Allow'][statement['NotAction']] = list(set(user['Permissions']['Allow'][statement['NotAction']])) # Remove duplicate resources 323 | return user 324 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | awscli 2 | boto3 3 | pandas 4 | argparse --------------------------------------------------------------------------------