├── .gitignore ├── LICENSE ├── README.md ├── docs └── example-path.png ├── iamgraph ├── __init__.py ├── analysis.py ├── cli.py ├── db.py ├── graph.py ├── parsing.py └── utils.py ├── poetry.lock └── pyproject.toml /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | !/.gitignore 3 | __pycache__/ 4 | model.vars 5 | data/ 6 | input/ 7 | 8 | __pycache__ 9 | *.py[cod] 10 | *$py.class 11 | 12 | # Distribution / packaging 13 | .Python build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | *.manifest 29 | *.spec 30 | 31 | # Log files 32 | pip-log.txt 33 | pip-delete-this-directory.txt 34 | *.log 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | .pytest_cache/ 43 | nosetests.xml 44 | coverage.xml 45 | *.cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # PyBuilder 53 | target/ 54 | 55 | # Jupyter Notebook 56 | .ipynb_checkpoints 57 | 58 | # IPython 59 | profile_default/ 60 | ipython_config.py 61 | 62 | # pyenv 63 | .python-version 64 | 65 | # pyflow 66 | __pypackages__/ 67 | 68 | # Environment 69 | .env 70 | .venv 71 | env/ 72 | venv/ 73 | ENV/ 74 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2023 Aleksi Kallio 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IAMGraph 2 | 3 | Tool to model AWS IAM role trust relationships and assume role paths to Neo4j graph database. The tool has been built to help in analysing IAM role trust relationships between multiple AWS accounts. It relies heavily on [Neo4j](https://neo4j.com/) to model and visualize the IAM resources and their interconnections and [IAMSpy](https://github.com/WithSecureLabs/IAMSpy/) to evaluate IAM permissions. 4 | 5 | 6 | ![example](docs/example-path.png "Example Path") 7 | 8 | IAMGraph works with the output of AWS CLI command `aws iam get-account-authorization-details` - access to target AWS accounts with permissions to run this command is needed. 9 | 10 | **Note:** Sometimes files generated by the `aws iam get-account-authorization-details` might miss some required data causing IAMSpy failing to load them. For example, when using permission boundaries, managed policies may not be included in the returned data if they are used by boundaries but nothing else. If IAMSpy fails to load some input data, analysis of these accounts is skipped. 11 | 12 | ## Installation 13 | 14 | ### Neo4j 15 | Neo4j database needs to be running and reachable. Neo4j has community edition available as [Docker image](https://hub.docker.com/_/neo4j/). For running the db locally, authentication can be disabled by passing `-e NEO4J_AUTH=none` environment variable to the container. If database authentication is configured, the database user needs to be passed to IAMGraph with `--db-user` flag and it will prompt for database password. 16 | 17 | For example, run Neo4j with podman/docker: 18 | ``` 19 | podman pull docker.io/library/neo4j:latest 20 | podman run \ 21 | -d -p 127.0.0.1:7474:7474 -p 127.0.0.1:7687:7687 \ 22 | -e NEO4J_AUTH=none \ 23 | -v $PWD/data:/data \ 24 | neo4j:latest 25 | ``` 26 | 27 | ### Install IAMGraph 28 | 29 | Clone the repository and install either: 30 | 31 | with [Poetry](https://python-poetry.org/docs/): `poetry install` 32 | 33 | OR 34 | 35 | with pip: `pip install .` 36 | 37 | ## Usage 38 | 39 | 1. Collect the needed IAM data by running `aws iam get-account-authorization-details` on every target account and store its output to a directory: 40 | ``` 41 | aws iam get-account-authorization-details --profile account-1 > ./input/account1.json 42 | aws iam get-account-authorization-details --profile account-2 > ./input/account2.json 43 | ... 44 | aws iam get-account-authorization-details --profile account-n > ./input/accountn.json 45 | ``` 46 | 47 | 2. Ensure Neo4j database is running and reachable 48 | 49 | 3. Run the tool. Give it the database endpoint and directory where the IAM data is stored as arguments: 50 | ``` 51 | iamgraph --db-uri bolt://localhost:7687 run --input-dir ./input/ 52 | ``` 53 | The individual stages (model and analyse) can also be run separaterly. See `iamgraph help` for details. 54 | 55 | 4. Navigate the Neo4j browser UI. With default configuration it is in: http://localhost:7474/browser/ 56 | 57 | 5. Execute [Cypher](https://neo4j.com/docs/cypher-manual/current/introduction/) queries in the browser UI to find interesting trust relationships and access paths. Some example queries and the data schema are described below. 58 | 59 | 60 | ## Example Queries 61 | 62 | Return all nodes and relationships in the database. With smaller datasets this can be used to explore the data. With larger datasets there will be too many nodes and relationships to show on the UI and more detailed queries are needed. 63 | 64 | ``` 65 | MATCH (n)-[r]-() RETURN * 66 | ``` 67 | Return roles that trust entities not part of the input dataset. If the input data contains data from all accounts of the organization, this helps to find roles that could potentially be assumed from outside the organization. 68 | 69 | ``` 70 | MATCH (a)<-[t:TRUSTS]-(r) WHERE a.InDataset=false RETURN * 71 | ``` 72 | Return all assume-role paths in the db. Note that with large datasets this can be too large to be shown in the Neo4j browser UI. 73 | ``` 74 | MATCH (src_a:Account)<-[i:IN]-(src_p:IAMPrincipal)-[ca:CAN_ASSUME]->(dst_r:IAMRole)-[ii:IN]->(dst_a:Account) RETURN * 75 | ``` 76 | Same as above, but limit the target account with the `WHERE` clause. 77 | ``` 78 | MATCH (src_a:Account)<-[i:IN]-(src_p:IAMPrincipal)-[ca:CAN_ASSUME*]->(dst_r:IAMRole)-[ii:IN]->(dst_a:Account) WHERE dst_a.AccountId='111111222222' RETURN * 79 | ``` 80 | Return roles trusting wildcard (`"*"`) principal: 81 | ``` 82 | MATCH (p:IAMPrincipal {ARN:'*'})<-[t:TRUSTS]-(n) RETURN * 83 | ``` 84 | Return roles trusting identity providers: 85 | ``` 86 | MATCH (r:IAMRole)-[t:TRUSTS]->(i:IdentityProvider) RETURN * 87 | ``` 88 | 89 | 90 | ## Schema 91 | 92 | Below are described the types of nodes and relationships IAMGraph generates to the database. This helps in crafting the Cypher queries to explore the generated model. 93 | 94 | ### Relationships 95 | 96 | IAMUser and IAMRole nodes that are part of the input dataset are all `IN` an Account 97 | ``` 98 | (IAMUser:IAMPrincipal)-[IN]->(Account) 99 | (IAMRole:IAMPrincipal)-[IN]->(Account) 100 | ``` 101 | Depending on the dataset, there might be IAMUser and IAMRole nodes not `IN` an account. This happens if a role trusts a principal not part of the dataset. Then, a node representing this external principal is created, but it's not `IN` any account. 102 | 103 | When IAM principal or account is defined in role's trust policy, the `IAMRole` node `TRUSTS` the principal. The `TRUST` relationships are created based on the parsed trust policies. 104 | ``` 105 | (IAMRole)-[TRUSTS]->(Account) 106 | (IAMRole)-[TRUSTS]->(IAMUser) 107 | (IAMRole)-[TRUSTS]->(IAMRole) 108 | (IAMRole)-[TRUSTS]->(IAMPrincipal) 109 | (IAMRole)-[TRUSTS]->(IdentityProvider) 110 | ``` 111 | 112 | IAM role and user `CAN_ASSUME` a role if they can effectively assume it. `CAN_ASSUME` relationships are created based on analysis with IAMSpy which evaluates all the relevant policies affecting whether a principal can assume the role. 113 | ``` 114 | (IAMRole)-[CAN_ASSUME]->(IAMRole) 115 | (IAMUser)-[CAN_ASSUME]->(IAMRole) 116 | ``` 117 | 118 | ### Nodes 119 | 120 | `Account`\ 121 | Properites of Account node: 122 | - **ARN** 123 | - **InDataset** - Accounts that have been part of the input data set 124 | - **AccountId** - AWS Account id of the account 125 | 126 | `IAMUser`\ 127 | Properties of IAMUser node: 128 | - **ARN** 129 | - **InDataset** 130 | - **Path** 131 | - **Name** 132 | - **UniqueID** 133 | - **InlinePolicies** 134 | - **ManagedPolicies** 135 | - **Groups** 136 | - **Tags** 137 | - **AccountId** 138 | 139 | `IAMRole`\ 140 | Properties of IAMRole node: 141 | - **ARN** 142 | - **InDataset** 143 | - **Path** 144 | - **Name** 145 | - **UniqueID** 146 | - **TrustPolicy** 147 | - **ManagedPolicies** 148 | - **InlinePolicies** 149 | - **Tags** 150 | - **LastUsed** 151 | - **IsInstanceProfile** 152 | - **AccountId** 153 | 154 | `IdentityProvider`\ 155 | Properties of IdentityProvider node: 156 | - **ARN** 157 | 158 | `IAMPrincipal`\ 159 | Represents an IAM principal. All `Account`, `IAMUser` and `IAMRole` nodes have also this label. Also the wildcard principal (\*) is represented with IAMPrincipal node where ARN property of the node is "\*". 160 | 161 | 162 | ## How It Works? 163 | 164 | When IAMGraph is executed, it does the following: 165 | 166 | 1. **Model** - The input IAM data is modelled as a graph. 167 | 1. Node representing every account, IAM user and role in the dataset is created. 168 | 2. Roles' trust policies are parsed to identify roles that trust an IAM principal, account or 169 | 3. From each such role, a `TRUSTS` relationship is created to the node it trusts. If the trusted node does not already exist in the database, the trusted principal was not part of the input dataset, and it is created. For such node, the `InDataset` property is set to `false`. 170 | 2. **Analyse** - After the input data is modelled to the database, the resulting graph is analysed with IAMSpy for effective assume-role paths 171 | 1. The graph model is queried for roles whose trust policy allows IAM principal or an account to assume the role 172 | 2. If the role trusts a whole account, IAMSpy is used to find principals in the trusted account that can assume the role. If the role trusts a user or role, IAMSpy is used to ensure the trusted principal can assume the role. 173 | 3. `CAN_ASSUME` relationship is created between principals and the roles they could effectively assume 174 | -------------------------------------------------------------------------------- /docs/example-path.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReversecLabs/IAMGraph/2e21ebefa47e3cf1e9ff7a8474573d0e120db302/docs/example-path.png -------------------------------------------------------------------------------- /iamgraph/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ReversecLabs/IAMGraph/2e21ebefa47e3cf1e9ff7a8474573d0e120db302/iamgraph/__init__.py -------------------------------------------------------------------------------- /iamgraph/analysis.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from iamspy.model import Model 4 | from multiprocessing import Pool 5 | from itertools import repeat, chain 6 | 7 | 8 | logger = logging.getLogger(__name__) 9 | 10 | 11 | PROCESSES = 10 12 | 13 | 14 | FIND_ROLE_TRUST_RELATIONSHIPS_CYPHER = ''' 15 | MATCH (a:Account)<-[i:IN]-(r:IAMRole)-[t:TRUSTS]->(tn:IAMPrincipal) 16 | WHERE tn.InDataset=true 17 | WITH collect(r.ARN) AS roles, tn, a.AccountId AS role_account, tn.AccountId AS trusted_account, labels(tn) AS l 18 | WITH {roles: roles, trusted_arn: tn.ARN, trusted_node_labels: l} AS roles_trusted_pair, role_account, trusted_account 19 | RETURN role_account, collect(roles_trusted_pair), trusted_account 20 | ''' 21 | 22 | CREATE_ASSUME_PATHS_CYPHER = ''' 23 | UNWIND $role_principal_pairs AS rp 24 | MATCH (trusting_role:IAMRole {ARN: rp.trusting_role}) 25 | UNWIND rp.principals AS p_arn 26 | MATCH (principal:IAMPrincipal {ARN: p_arn}) 27 | MERGE (principal)-[a:CAN_ASSUME]->(trusting_role) 28 | ''' 29 | 30 | 31 | def analyse_assume_role_permissions(row, aid_gaad_map): 32 | ret = [] 33 | role_account_id, roles_trusted_dicts, trusted_account_id = row 34 | logger.info(f'Analysing assume-role paths between accounts {role_account_id} and {trusted_account_id}') 35 | 36 | model = Model() 37 | 38 | try: 39 | model.load_gaad(aid_gaad_map[int(role_account_id)]) 40 | if role_account_id != trusted_account_id: 41 | model.load_gaad(aid_gaad_map[int(trusted_account_id)]) 42 | except Exception as e: 43 | logger.error( 44 | f'IAMSpy failed to load GAADs of {role_account_id} and/or {trusted_account_id}. ' 45 | f'This is likely an issue with the input data. IAMSpy exception: {repr(e)} Skipping these accounts!!' 46 | ) 47 | return [] 48 | 49 | for roles_trusted in roles_trusted_dicts: 50 | trusted_node_labels = roles_trusted.get('trusted_node_labels') 51 | trusting_roles = roles_trusted.get('roles') 52 | trusted_arn = roles_trusted.get('trusted_arn') 53 | 54 | if 'Account' in trusted_node_labels: 55 | for role in trusting_roles: 56 | logger.debug(f'IAMSpying potential path between {role} and account {trusted_arn}') 57 | if principals := model.who_can('sts:AssumeRole', role): 58 | ret.append({ 59 | 'trusting_role': role, 60 | 'principals': principals 61 | }) 62 | 63 | elif ('IAMRole' in trusted_node_labels) or ('IAMUser' in trusted_node_labels): 64 | for role in trusting_roles: 65 | logger.debug(f'IAMSpying potential path between {role} and principal {trusted_arn}') 66 | if model.can_i(trusted_arn, 'sts:AssumeRole', role): 67 | ret.append({ 68 | 'trusting_role': role, 69 | 'principals': [trusted_arn] 70 | }) 71 | return ret 72 | 73 | 74 | def find_assume_role_paths(db, aid_gaad_map, processes=PROCESSES): 75 | # find potential roles 76 | logger.info('Querying role trust relationships from the graph') 77 | ret = db.run(FIND_ROLE_TRUST_RELATIONSHIPS_CYPHER) 78 | role_principal_pairs = [] 79 | 80 | # do the analysis with IAMSpy 81 | logger.info('Analysing the found relationships for assume role paths') 82 | with Pool(processes) as p: 83 | role_principal_pairs = list(chain.from_iterable( 84 | p.starmap(analyse_assume_role_permissions, zip(ret, repeat(aid_gaad_map))) 85 | )) 86 | 87 | # create assume role paths based on the analysis 88 | logger.info('Writing the found paths to the database') 89 | db.run(CREATE_ASSUME_PATHS_CYPHER, 90 | role_principal_pairs=role_principal_pairs) 91 | -------------------------------------------------------------------------------- /iamgraph/cli.py: -------------------------------------------------------------------------------- 1 | import click 2 | import os 3 | 4 | from iamgraph.analysis import find_assume_role_paths 5 | from iamgraph.db import Neo4jDB, clear_neo4j 6 | from iamgraph.graph import model_gaads_to_graph 7 | from iamgraph.utils import list_files_on_dir, generate_aid_gaad_map, configure_logging 8 | 9 | 10 | @click.group() 11 | @click.option('--db-uri', '-du', 12 | default='bolt://localhost:7687', show_default=True, 13 | help=''' 14 | URI of Neo4j database instance. 15 | ''') 16 | @click.option('--db-user', '-u', 17 | required=False, 18 | help=''' 19 | User used to authenticate to Neo4j database instance. 20 | ''') 21 | @click.option('-v', '--verbose', 22 | count=True, default=0, 23 | help=''' 24 | Verbosity level. Repeat for more log output. 25 | ''') 26 | @click.pass_context 27 | def cli(ctx, db_uri, db_user, verbose): 28 | configure_logging(verbose) 29 | db_password = None 30 | if db_user: 31 | db_password = click.prompt( 32 | f'Give password for database user {db_user} to authenticate to the db', 33 | hide_input=True, default='', show_default=False 34 | ) 35 | db = Neo4jDB(uri=db_uri, db_user=db_user, db_pwd=db_password) 36 | ctx.ensure_object(dict) 37 | ctx.obj['db'] = db 38 | 39 | 40 | @cli.command(help='Model the IAM configurations of the target accounts to a graph') 41 | @click.option('--input-dir', '-i', 42 | required=True, 43 | help=''' 44 | A name of the directory containing files with output from\n 45 | aws iam get-account-authorization-details\n 46 | from each target account 47 | ''') 48 | @click.option('--clear-db', '-c', 49 | default=False, is_flag=True, 50 | help=''' 51 | Clear the contents of the db before ingesting the data 52 | ''') 53 | @click.pass_context 54 | def model(ctx, input_dir, clear_db): 55 | db = ctx.obj['db'] 56 | if clear_db: 57 | print('Clearing the contents of the database') 58 | clear_neo4j(db) 59 | if os.path.isfile(input_dir): 60 | input_files = [input_dir] 61 | elif os.path.isdir(input_dir) and not (input_files := list_files_on_dir(input_dir)): 62 | print(f'Provided input directory {input_dir} is empty') 63 | return 64 | 65 | print(f'Modelling input files from {input_dir} to the graph...') 66 | model_gaads_to_graph(db, input_files) 67 | print('Modelling ready') 68 | 69 | 70 | @cli.command(help='Analyse the graph modelled to the database with IAMSpy. Note that ' 71 | 'this command needs to be run AFTER the "model" command') 72 | @click.option('--input-dir', '-i', 73 | required=True, 74 | help=''' 75 | A name of the directory containing files with output from\n 76 | aws iam get-account-authorization-details\n 77 | from each target account 78 | ''') 79 | @click.option('--processes', '-p', 80 | required=False, default=10, show_default=True, 81 | help=''' 82 | Number of processes used for multiprocessing 83 | ''') 84 | @click.pass_context 85 | def analyse(ctx, input_dir, processes): 86 | db = ctx.obj['db'] 87 | if not (aid_gaad_map := generate_aid_gaad_map(input_dir)): 88 | print(f'Provided input directory {input_dir} is empty') 89 | return 90 | print('Analysing the graph with IAMSpy to find assume role paths...') 91 | find_assume_role_paths(db, aid_gaad_map, processes) 92 | print('IAMSpy analysis ready') 93 | 94 | 95 | @cli.command(help='Run both model and analyse') 96 | @click.option('--input-dir', '-i', 97 | required=True, 98 | help=''' 99 | A name of the directory containing files with output from\n 100 | aws iam get-account-authorization-details\n 101 | from each target account 102 | ''') 103 | @click.option('--clear-db', '-c', 104 | default=False, is_flag=True, 105 | help=''' 106 | Clear the contents of the db before ingesting the data 107 | ''') 108 | @click.option('--processes', '-p', 109 | required=False, default=10, show_default=True, 110 | help=''' 111 | Number of processes used for multiprocessing 112 | ''') 113 | def run(input_dir, clear_db, processes): 114 | model.callback(input_dir=input_dir, clear_db=clear_db) 115 | analyse.callback(input_dir=input_dir, processes=processes) 116 | print('Done! Query the resulting graph with Neo4j browser UI') 117 | 118 | 119 | if __name__ == '__main__': 120 | cli() 121 | -------------------------------------------------------------------------------- /iamgraph/db.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from neo4j import GraphDatabase 4 | from neo4j.exceptions import ServiceUnavailable, AuthError 5 | 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | def clear_neo4j(db): 11 | logger.info('Clearing the db') 12 | 13 | QUERY = ''' 14 | match (a) -[r] -> () delete a, r 15 | ''' 16 | db.run(QUERY) 17 | 18 | QUERY = ''' 19 | match (a) delete a 20 | ''' 21 | db.run(QUERY) 22 | 23 | 24 | class Neo4jDB(object): 25 | def __init__(self, db_user=None, uri='bolt://localhost:7687', db_pwd=None): 26 | self._driver = GraphDatabase.driver(uri, auth=(db_user, db_pwd)) 27 | 28 | def close(self): 29 | self._driver.close() 30 | 31 | def run(self, cypher: str, **kwargs): 32 | try: 33 | with self._driver.session() as session: 34 | res = session.run(cypher, **kwargs) 35 | ret = res.values() 36 | return ret 37 | except AuthError as e: 38 | logger.error('Failed to authenticate to the Neo4j database. Ensure you\'ve configured the ' 39 | 'needed credentials correctly') 40 | raise e 41 | except ServiceUnavailable as e: 42 | logger.error('Failed to connect to the Neo4j database. Ensure it is running and reachable ' 43 | 'and you\'ve provided the correct URI') 44 | raise e -------------------------------------------------------------------------------- /iamgraph/graph.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import json 3 | 4 | from iamgraph.parsing import parse_gaad 5 | 6 | 7 | logger = logging.getLogger(__name__) 8 | 9 | 10 | def model_gaads_to_graph(db, input_gaads): 11 | logger.info(f'Input files: {input_gaads}') 12 | parsed_gaads = [] 13 | # Parse and collect GAADs to the db 14 | for input_file in input_gaads: 15 | with open(input_file, 'r') as f: 16 | iam_details = json.load(f) 17 | 18 | logger.info(f'Parsing input file: {input_file}') 19 | parsed_iam_details = parse_gaad(iam_details) 20 | 21 | # Collect data from gaad to the database 22 | model_account(db, parsed_iam_details['AccountDetails']) 23 | model_users(db, parsed_iam_details['Users']) 24 | model_roles(db, parsed_iam_details['Roles']) 25 | 26 | parsed_gaads.append(parsed_iam_details) 27 | 28 | # Trust relationships are created after all in scope IAM principals are collected to the db 29 | model_trust_relationships(db, parsed_gaads) 30 | 31 | 32 | def model_roles(db, parsed_roles): 33 | CYPHER = ''' 34 | UNWIND $roles AS r 35 | MATCH (account:Account {AccountId: r.AccountId}) 36 | WITH account, r 37 | 38 | MERGE (role:IAMRole {ARN: r.Arn}) 39 | SET role:IAMPrincipal 40 | SET role.Path = r.Path 41 | SET role.Name = r.RoleName 42 | SET role.UniqueID = r.RoleId 43 | SET role.TrustPolicy = r.RawTrustPolicy 44 | SET role.ManagedPolicies = r.ManagedPolicies 45 | SET role.InlinePolicies = r.InlinePolicies 46 | SET role.Tags = r.Tags 47 | SET role.LastUsed = r.LastUsed 48 | SET role.IsInstanceProfile = r.IsInstanceProfile 49 | SET role.InDataset = true 50 | SET role.AccountId = r.AccountId 51 | MERGE (role)-[:IN]->(account) 52 | ''' 53 | db.run(CYPHER, roles=parsed_roles) 54 | logger.info('IAM roles collected to the database') 55 | 56 | 57 | def model_trust_relationships(db, parsed_gaads): 58 | CYPHER = ''' 59 | UNWIND $gaads AS gaad 60 | WITH gaad.Roles as roles 61 | 62 | UNWIND roles AS r 63 | MATCH (role:IAMRole {ARN: r.Arn}) 64 | WITH r, role 65 | UNWIND r.ParsedTrustStatements AS ts 66 | WITH ts, role 67 | WHERE ts.TrustsIamPrincipal = true 68 | UNWIND ts.TrustedPrincipals AS trusted 69 | WITH role, trusted 70 | 71 | // FOREACH hack to do conditional operations in cypher without needing APOC 72 | FOREACH (_ IN CASE WHEN trusted.Type='role' THEN [1] ELSE [] END | 73 | MERGE (tn:IAMRole {ARN: trusted.Id}) 74 | ON CREATE 75 | SET tn.InDataset = false 76 | SET tn:IAMPrincipal 77 | MERGE (role)-[t:TRUSTS]->(tn) 78 | ) 79 | FOREACH (_ IN CASE WHEN trusted.Type='user' THEN [1] ELSE [] END | 80 | MERGE (tn:IAMUser {ARN: trusted.Id}) 81 | ON CREATE 82 | SET tn.InDataset = false 83 | SET tn:IAMPrincipal 84 | MERGE (role)-[t:TRUSTS]->(tn) 85 | ) 86 | FOREACH (_ IN CASE WHEN trusted.Type='account' THEN [1] ELSE [] END | 87 | MERGE (tn:Account {ARN: trusted.Id}) 88 | ON CREATE 89 | SET tn.InDataset = false 90 | SET tn:IAMPrincipal 91 | MERGE (role)-[t:TRUSTS]->(tn) 92 | ) 93 | FOREACH (_ IN CASE WHEN trusted.Type='uid' THEN [1] ELSE [] END | 94 | MERGE (tn:IAMPrincipal {UniqueID: trusted.Id}) 95 | ON CREATE 96 | SET tn.InDataset = false 97 | MERGE (role)-[t:TRUSTS]->(tn) 98 | ) 99 | FOREACH (_ IN CASE WHEN trusted.Type='any' THEN [1] ELSE [] END | 100 | MERGE (tn:IAMPrincipal {ARN: trusted.Id}) 101 | ON CREATE 102 | SET tn.InDataset = false 103 | MERGE (role)-[t:TRUSTS]->(tn) 104 | ) 105 | FOREACH (_ IN CASE WHEN trusted.Type='identity_provider' THEN [1] ELSE [] END | 106 | MERGE (tn:IdentityProvider {ARN: trusted.Id}) 107 | ON CREATE 108 | SET tn.InDataset = false 109 | SET tn:IAMPrincipal 110 | MERGE (role)-[t:TRUSTS]->(tn) 111 | ) 112 | // Default case 113 | FOREACH (_ IN CASE WHEN NOT trusted.Type IN ['account', 'role', 'user', 'uid', 'any', 'identity_provider'] THEN [1] ELSE [] END | 114 | MERGE (tn:UNKNOWN {Identifier: trusted.Id}) 115 | MERGE (role)-[t:TRUSTS]->(tn) 116 | ) 117 | ''' 118 | logger.info('Creating trust relationships from IAM roles') 119 | db.run(CYPHER, gaads=parsed_gaads) 120 | logger.info('Trust Relationships Created') 121 | 122 | 123 | def model_users(db, users): 124 | CYPHER = ''' 125 | UNWIND $users AS u 126 | 127 | MATCH (account:Account {AccountId: u.AccountId}) 128 | WITH account, u 129 | 130 | MERGE (user:IAMUser {ARN: u.Arn}) 131 | SET user:IAMPrincipal 132 | SET user.Path = u.Path 133 | SET user.Name = u.UserName 134 | SET user.UniqueID = u.UserId 135 | SET user.InlinePolicies = u.UserPolicyList 136 | SET user.ManagedPolicies = u.AttachedManagedPolicies 137 | SET user.Groups = u.GroupList 138 | SET user.Tags = u.Tags 139 | SET user.InDataset = true 140 | SET user.AccountId = u.AccountId 141 | MERGE (user)-[:IN]->(account) 142 | ''' 143 | db.run(CYPHER, users=users) 144 | logger.info('IAM users collected to the database') 145 | 146 | 147 | def model_account(db, account_details): 148 | CYPHER = ''' 149 | MERGE (account:Account {ARN: $a.AccountArn}) 150 | SET account:IAMPrincipal 151 | SET account.InDataset = true 152 | SET account.AccountId = $a.AccountId 153 | WITH account, $a.Prod AS prodAccount WHERE prodAccount = true 154 | SET account.Prod = true 155 | ''' 156 | db.run(CYPHER, a=account_details) 157 | logger.info(f'Account {account_details["AccountId"]} collected to the db') 158 | -------------------------------------------------------------------------------- /iamgraph/parsing.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | import logging 4 | 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | def parse_gaad(authorization_details): 10 | roles, account_details = parse_roles(authorization_details.get('RoleDetailList', [])) 11 | users = parse_users(authorization_details.get('UserDetailList', [])) 12 | return { 13 | 'AccountDetails': account_details, 14 | 'Roles': roles, 15 | 'Users': users 16 | } 17 | 18 | 19 | def parse_users(users): 20 | for user in users: 21 | user['AccountId'] = user.get('Arn').split(':')[4] 22 | user['UserPolicyList'] = json.dumps(user.get('UserPolicyList', []), indent=4) 23 | user['AttachedManagedPolicies'] = json.dumps(user.get('AttachedManagedPolicies', []), indent=4) 24 | user['GroupList'] = json.dumps(user.get('GroupList', []), indent=4) 25 | user['Tags'] = json.dumps(user.get('Tags', []), indent=4) 26 | return users 27 | 28 | 29 | def parse_roles(roles): 30 | for role in roles: 31 | trust_policy = role.get('AssumeRolePolicyDocument') 32 | account_id = role.get('Arn').split(':')[4] 33 | role['AccountId'] = account_id 34 | role['ParsedTrustStatements'] = parse_trust_policy_document(trust_policy) 35 | role['RawTrustPolicy'] = json.dumps(trust_policy, indent=4) 36 | role['ManagedPolicies'] = json.dumps(role.get('AttachedManagedPolicies', []), indent=4) 37 | role['InlinePolicies'] = json.dumps(role.get('RolePolicyList', []), indent=4) 38 | role['Tags'] = json.dumps(role.get('Tags', []), indent=4) 39 | role['LastUsed'] = json.dumps(role.get('RoleLastUsed', {}), indent=4) 40 | role['IsInstanceProfile'] = bool(role.get('InstanceProfileList', [])) 41 | 42 | account_details = { 43 | 'AccountId': account_id, 44 | 'AccountArn': f'arn:aws:iam::{account_id}:root' 45 | } 46 | return roles, account_details 47 | 48 | 49 | def parse_trust_policy_document(policy_document): 50 | # IAM Policy reference: 51 | # https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_grammar.html 52 | parsed_statements = [] 53 | for statement in policy_document.get('Statement'): 54 | trusts_service = False 55 | trusted_services = None 56 | trusts_iam_principal = False 57 | trusted_principals = [] 58 | 59 | principal_block = statement.get('Principal') 60 | if principal_block == {'AWS': '*'} or principal_block == '*': 61 | trusted_principals.append({ 62 | 'Type': 'any', 63 | 'Id': '*' 64 | }) 65 | trusts_iam_principal = True 66 | logger.warn('!!!Role trust policy allows public access (*)!!!') 67 | 68 | else: 69 | if trusted_services := principal_block.get('Service', None): 70 | trusts_service = True 71 | if isinstance(trusted_services, str): 72 | trusted_services = [trusted_services] 73 | 74 | if trusts := principal_block.get('AWS', None): 75 | trusts_iam_principal = True 76 | if isinstance(trusts, str): 77 | trusts = [trusts] 78 | 79 | for t in trusts: 80 | # t is principal_id_string can be either account_id or ARN: 81 | # 123456789012 82 | # arn:aws:iam::123456789012:root 83 | # arn:aws:iam::123456789012:role/somerole 84 | # arn:aws:iam::123456789012:user/someuser 85 | # OR Unique ID like: 86 | # "AIDACKCEVSQ6C2EXAMPLE", 87 | # "AROADBQP57FF2AEXAMPLE" 88 | try: 89 | re.findall('\d{12}', t)[0] 90 | except IndexError: 91 | # unique ID 92 | trusted_principals.append({ 93 | 'Type': 'uid', 94 | 'Id': t 95 | }) 96 | continue 97 | 98 | splitted_arn = t.split(':') 99 | if len(splitted_arn) > 1: 100 | if 'root' == splitted_arn[-1]: 101 | resource_type = 'account' 102 | else: 103 | resource_type = splitted_arn[-1].split('/')[0] 104 | 105 | trusted_principals.append({ 106 | 'Type': resource_type, 107 | 'Id': t 108 | }) 109 | 110 | else: 111 | # trusted principal was in a form of just account id 112 | # convert it to arn 113 | trusted_principals.append({ 114 | 'Type': 'account', 115 | 'Id': f'arn:aws:iam::{t}:root' 116 | }) 117 | 118 | if identity_provider_arn := principal_block.get('Federated', None): 119 | trusts_iam_principal = True 120 | trusted_principals.append({ 121 | 'Type': 'identity_provider', 122 | 'Id': str(identity_provider_arn) 123 | }) 124 | 125 | parsed_statements.append( 126 | { 127 | 'TrustsService': trusts_service, 128 | 'TrustedServices': trusted_services, 129 | 'TrustsIamPrincipal': trusts_iam_principal, 130 | 'TrustedPrincipals': trusted_principals 131 | } 132 | ) 133 | 134 | return parsed_statements 135 | -------------------------------------------------------------------------------- /iamgraph/utils.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | 5 | 6 | logger = logging.getLogger(__name__) 7 | 8 | 9 | def generate_aid_gaad_map(input_dir): 10 | if os.path.isfile(input_dir): 11 | input_gaads = [input_dir] 12 | elif not (input_gaads := list_files_on_dir(input_dir)): 13 | logger.error(f'Provided input directory {input_dir} is empty') 14 | 15 | account_id_gaad_location_map = {} 16 | for input_file in input_gaads: 17 | with open(input_file, 'r') as f: 18 | iam_details = json.load(f) 19 | account_id = iam_details.get('RoleDetailList')[0].get('Arn').split(':')[4] 20 | account_id_gaad_location_map[int(account_id)] = input_file 21 | return account_id_gaad_location_map 22 | 23 | 24 | def list_files_on_dir(input_dir): 25 | if not (os.path.exists(input_dir)): 26 | logger.error(f'Provided input directory {input_dir} does not exist!') 27 | return None 28 | 29 | input_files = [os.path.join(input_dir, f) for f in os.listdir(input_dir) 30 | if os.path.isfile(os.path.join(input_dir, f))] 31 | 32 | return input_files 33 | 34 | 35 | def configure_logging(verbosity): 36 | level = { 37 | 0: logging.ERROR, 38 | 1: logging.WARNING, 39 | 2: logging.INFO, 40 | 3: logging.DEBUG, 41 | }.get(verbosity, 0) 42 | logging.basicConfig(level=level) 43 | 44 | # IAMSpy generates a LOT of logs and since we run it in parallel 45 | # those are impossible to follow 46 | logging.getLogger('iamspy').setLevel(logging.ERROR) 47 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "click" 5 | version = "8.1.7" 6 | description = "Composable command line interface toolkit" 7 | optional = false 8 | python-versions = ">=3.7" 9 | files = [ 10 | {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, 11 | {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, 12 | ] 13 | 14 | [package.dependencies] 15 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 16 | 17 | [[package]] 18 | name = "colorama" 19 | version = "0.4.6" 20 | description = "Cross-platform colored terminal text." 21 | optional = false 22 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 23 | files = [ 24 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 25 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 26 | ] 27 | 28 | [[package]] 29 | name = "iamspy" 30 | version = "0.1.0" 31 | description = "" 32 | optional = false 33 | python-versions = "^3.9" 34 | files = [] 35 | develop = false 36 | 37 | [package.dependencies] 38 | pydantic = "^1" 39 | python-dateutil = "^2.8.2" 40 | typer = "*" 41 | z3-solver = "*" 42 | 43 | [package.source] 44 | type = "git" 45 | url = "https://github.com/WithSecureLabs/IAMSpy.git" 46 | reference = "f48e74b9f21200352ba2283d87b720b0192e0aa5" 47 | resolved_reference = "f48e74b9f21200352ba2283d87b720b0192e0aa5" 48 | 49 | [[package]] 50 | name = "neo4j" 51 | version = "5.18.0" 52 | description = "Neo4j Bolt driver for Python" 53 | optional = false 54 | python-versions = ">=3.7" 55 | files = [ 56 | {file = "neo4j-5.18.0.tar.gz", hash = "sha256:4014406ae5b8b485a8ba46c9f00b6f5b4aaf88e7c3a50603445030c2aab701c9"}, 57 | ] 58 | 59 | [package.dependencies] 60 | pytz = "*" 61 | 62 | [package.extras] 63 | numpy = ["numpy (>=1.7.0,<2.0.0)"] 64 | pandas = ["numpy (>=1.7.0,<2.0.0)", "pandas (>=1.1.0,<3.0.0)"] 65 | pyarrow = ["pyarrow (>=1.0.0)"] 66 | 67 | [[package]] 68 | name = "pydantic" 69 | version = "1.10.14" 70 | description = "Data validation and settings management using python type hints" 71 | optional = false 72 | python-versions = ">=3.7" 73 | files = [ 74 | {file = "pydantic-1.10.14-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7f4fcec873f90537c382840f330b90f4715eebc2bc9925f04cb92de593eae054"}, 75 | {file = "pydantic-1.10.14-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8e3a76f571970fcd3c43ad982daf936ae39b3e90b8a2e96c04113a369869dc87"}, 76 | {file = "pydantic-1.10.14-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82d886bd3c3fbeaa963692ef6b643159ccb4b4cefaf7ff1617720cbead04fd1d"}, 77 | {file = "pydantic-1.10.14-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:798a3d05ee3b71967844a1164fd5bdb8c22c6d674f26274e78b9f29d81770c4e"}, 78 | {file = "pydantic-1.10.14-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:23d47a4b57a38e8652bcab15a658fdb13c785b9ce217cc3a729504ab4e1d6bc9"}, 79 | {file = "pydantic-1.10.14-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f9f674b5c3bebc2eba401de64f29948ae1e646ba2735f884d1594c5f675d6f2a"}, 80 | {file = "pydantic-1.10.14-cp310-cp310-win_amd64.whl", hash = "sha256:24a7679fab2e0eeedb5a8924fc4a694b3bcaac7d305aeeac72dd7d4e05ecbebf"}, 81 | {file = "pydantic-1.10.14-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9d578ac4bf7fdf10ce14caba6f734c178379bd35c486c6deb6f49006e1ba78a7"}, 82 | {file = "pydantic-1.10.14-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa7790e94c60f809c95602a26d906eba01a0abee9cc24150e4ce2189352deb1b"}, 83 | {file = "pydantic-1.10.14-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad4e10efa5474ed1a611b6d7f0d130f4aafadceb73c11d9e72823e8f508e663"}, 84 | {file = "pydantic-1.10.14-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1245f4f61f467cb3dfeced2b119afef3db386aec3d24a22a1de08c65038b255f"}, 85 | {file = "pydantic-1.10.14-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:21efacc678a11114c765eb52ec0db62edffa89e9a562a94cbf8fa10b5db5c046"}, 86 | {file = "pydantic-1.10.14-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:412ab4a3f6dbd2bf18aefa9f79c7cca23744846b31f1d6555c2ee2b05a2e14ca"}, 87 | {file = "pydantic-1.10.14-cp311-cp311-win_amd64.whl", hash = "sha256:e897c9f35281f7889873a3e6d6b69aa1447ceb024e8495a5f0d02ecd17742a7f"}, 88 | {file = "pydantic-1.10.14-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:d604be0f0b44d473e54fdcb12302495fe0467c56509a2f80483476f3ba92b33c"}, 89 | {file = "pydantic-1.10.14-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a42c7d17706911199798d4c464b352e640cab4351efe69c2267823d619a937e5"}, 90 | {file = "pydantic-1.10.14-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:596f12a1085e38dbda5cbb874d0973303e34227b400b6414782bf205cc14940c"}, 91 | {file = "pydantic-1.10.14-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bfb113860e9288d0886e3b9e49d9cf4a9d48b441f52ded7d96db7819028514cc"}, 92 | {file = "pydantic-1.10.14-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:bc3ed06ab13660b565eed80887fcfbc0070f0aa0691fbb351657041d3e874efe"}, 93 | {file = "pydantic-1.10.14-cp37-cp37m-win_amd64.whl", hash = "sha256:ad8c2bc677ae5f6dbd3cf92f2c7dc613507eafe8f71719727cbc0a7dec9a8c01"}, 94 | {file = "pydantic-1.10.14-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c37c28449752bb1f47975d22ef2882d70513c546f8f37201e0fec3a97b816eee"}, 95 | {file = "pydantic-1.10.14-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:49a46a0994dd551ec051986806122767cf144b9702e31d47f6d493c336462597"}, 96 | {file = "pydantic-1.10.14-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:53e3819bd20a42470d6dd0fe7fc1c121c92247bca104ce608e609b59bc7a77ee"}, 97 | {file = "pydantic-1.10.14-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0fbb503bbbbab0c588ed3cd21975a1d0d4163b87e360fec17a792f7d8c4ff29f"}, 98 | {file = "pydantic-1.10.14-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:336709883c15c050b9c55a63d6c7ff09be883dbc17805d2b063395dd9d9d0022"}, 99 | {file = "pydantic-1.10.14-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:4ae57b4d8e3312d486e2498d42aed3ece7b51848336964e43abbf9671584e67f"}, 100 | {file = "pydantic-1.10.14-cp38-cp38-win_amd64.whl", hash = "sha256:dba49d52500c35cfec0b28aa8b3ea5c37c9df183ffc7210b10ff2a415c125c4a"}, 101 | {file = "pydantic-1.10.14-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c66609e138c31cba607d8e2a7b6a5dc38979a06c900815495b2d90ce6ded35b4"}, 102 | {file = "pydantic-1.10.14-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:d986e115e0b39604b9eee3507987368ff8148222da213cd38c359f6f57b3b347"}, 103 | {file = "pydantic-1.10.14-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:646b2b12df4295b4c3148850c85bff29ef6d0d9621a8d091e98094871a62e5c7"}, 104 | {file = "pydantic-1.10.14-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282613a5969c47c83a8710cc8bfd1e70c9223feb76566f74683af889faadc0ea"}, 105 | {file = "pydantic-1.10.14-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:466669501d08ad8eb3c4fecd991c5e793c4e0bbd62299d05111d4f827cded64f"}, 106 | {file = "pydantic-1.10.14-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:13e86a19dca96373dcf3190fcb8797d40a6f12f154a244a8d1e8e03b8f280593"}, 107 | {file = "pydantic-1.10.14-cp39-cp39-win_amd64.whl", hash = "sha256:08b6ec0917c30861e3fe71a93be1648a2aa4f62f866142ba21670b24444d7fd8"}, 108 | {file = "pydantic-1.10.14-py3-none-any.whl", hash = "sha256:8ee853cd12ac2ddbf0ecbac1c289f95882b2d4482258048079d13be700aa114c"}, 109 | {file = "pydantic-1.10.14.tar.gz", hash = "sha256:46f17b832fe27de7850896f3afee50ea682220dd218f7e9c88d436788419dca6"}, 110 | ] 111 | 112 | [package.dependencies] 113 | typing-extensions = ">=4.2.0" 114 | 115 | [package.extras] 116 | dotenv = ["python-dotenv (>=0.10.4)"] 117 | email = ["email-validator (>=1.0.3)"] 118 | 119 | [[package]] 120 | name = "python-dateutil" 121 | version = "2.9.0.post0" 122 | description = "Extensions to the standard Python datetime module" 123 | optional = false 124 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" 125 | files = [ 126 | {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, 127 | {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, 128 | ] 129 | 130 | [package.dependencies] 131 | six = ">=1.5" 132 | 133 | [[package]] 134 | name = "pytz" 135 | version = "2024.1" 136 | description = "World timezone definitions, modern and historical" 137 | optional = false 138 | python-versions = "*" 139 | files = [ 140 | {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"}, 141 | {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, 142 | ] 143 | 144 | [[package]] 145 | name = "six" 146 | version = "1.16.0" 147 | description = "Python 2 and 3 compatibility utilities" 148 | optional = false 149 | python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" 150 | files = [ 151 | {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, 152 | {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, 153 | ] 154 | 155 | [[package]] 156 | name = "typer" 157 | version = "0.9.0" 158 | description = "Typer, build great CLIs. Easy to code. Based on Python type hints." 159 | optional = false 160 | python-versions = ">=3.6" 161 | files = [ 162 | {file = "typer-0.9.0-py3-none-any.whl", hash = "sha256:5d96d986a21493606a358cae4461bd8cdf83cbf33a5aa950ae629ca3b51467ee"}, 163 | {file = "typer-0.9.0.tar.gz", hash = "sha256:50922fd79aea2f4751a8e0408ff10d2662bd0c8bbfa84755a699f3bada2978b2"}, 164 | ] 165 | 166 | [package.dependencies] 167 | click = ">=7.1.1,<9.0.0" 168 | typing-extensions = ">=3.7.4.3" 169 | 170 | [package.extras] 171 | all = ["colorama (>=0.4.3,<0.5.0)", "rich (>=10.11.0,<14.0.0)", "shellingham (>=1.3.0,<2.0.0)"] 172 | dev = ["autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "pre-commit (>=2.17.0,<3.0.0)"] 173 | doc = ["cairosvg (>=2.5.2,<3.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pillow (>=9.3.0,<10.0.0)"] 174 | test = ["black (>=22.3.0,<23.0.0)", "coverage (>=6.2,<7.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.910)", "pytest (>=4.4.0,<8.0.0)", "pytest-cov (>=2.10.0,<5.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "pytest-xdist (>=1.32.0,<4.0.0)", "rich (>=10.11.0,<14.0.0)", "shellingham (>=1.3.0,<2.0.0)"] 175 | 176 | [[package]] 177 | name = "typing-extensions" 178 | version = "4.10.0" 179 | description = "Backported and Experimental Type Hints for Python 3.8+" 180 | optional = false 181 | python-versions = ">=3.8" 182 | files = [ 183 | {file = "typing_extensions-4.10.0-py3-none-any.whl", hash = "sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475"}, 184 | {file = "typing_extensions-4.10.0.tar.gz", hash = "sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb"}, 185 | ] 186 | 187 | [[package]] 188 | name = "z3-solver" 189 | version = "4.12.6.0" 190 | description = "an efficient SMT solver library" 191 | optional = false 192 | python-versions = "*" 193 | files = [ 194 | {file = "z3-solver-4.12.6.0.tar.gz", hash = "sha256:9a0f30e9648ee2649adc301dbf91470bc1600f34112ffeaf1063f8a8075e1a48"}, 195 | {file = "z3_solver-4.12.6.0-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:9347c9c4f966c97c807f73b1b4365d11dbb25e390b3908b1c3a6e3bceff62e6d"}, 196 | {file = "z3_solver-4.12.6.0-py2.py3-none-macosx_11_0_x86_64.whl", hash = "sha256:359d2af6dbcc017dadb99f286b39df101edf6ac9a45332ba15c0a2012ff46d45"}, 197 | {file = "z3_solver-4.12.6.0-py2.py3-none-manylinux2014_x86_64.whl", hash = "sha256:dca097b9a3026a63019eba65fe7ad120dcccec847cc33a7c0e548cfe324680fc"}, 198 | {file = "z3_solver-4.12.6.0-py2.py3-none-win32.whl", hash = "sha256:d795fd1a25416f9164ba6be8750c66732d998c645234b6d7f1b801b7ba5f67d4"}, 199 | {file = "z3_solver-4.12.6.0-py2.py3-none-win_amd64.whl", hash = "sha256:95a3725f43ec8cf2d4e7733327cdf2b9ff9caa09e0511e2b1a059000f211f605"}, 200 | ] 201 | 202 | [metadata] 203 | lock-version = "2.0" 204 | python-versions = "^3.9" 205 | content-hash = "6a5c94f7ada8b3cd682af7c262a4c347e97e35db2d66f74b2bfe4ad3896b4dcc" 206 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "iamgraph" 3 | version = "0.1.0" 4 | description = "Tool to model AWS IAM relationships to Neo4j graph database" 5 | authors = ["Aleksi Kallio "] 6 | readme = "README.md" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.9" 10 | neo4j = "^5.15.0" 11 | click = "^8.1.7" 12 | iamspy = {git = "https://github.com/WithSecureLabs/IAMSpy.git", rev = "f48e74b9f21200352ba2283d87b720b0192e0aa5"} 13 | 14 | [tool.poetry.scripts] 15 | iamgraph = "iamgraph.cli:cli" 16 | 17 | [build-system] 18 | requires = ["poetry-core"] 19 | build-backend = "poetry.core.masonry.api" 20 | --------------------------------------------------------------------------------