├── requirements.txt ├── examples ├── example-viz.png ├── sample_cf_template.json ├── gather_and_analyze.py ├── graph_from_cf_template.py └── example-privesc-only-viz.svg ├── MANIFEST.in ├── Dockerfile ├── .gitignore ├── .github ├── ISSUE_TEMPLATE │ ├── quick-q.md │ ├── bug-report.md │ └── auth-report.md └── workflows │ └── test_PRs.yml ├── principalmapper ├── querying │ ├── presets │ │ ├── __init__.py │ │ ├── clusters.py │ │ ├── serviceaccess.py │ │ ├── connected.py │ │ ├── privesc.py │ │ ├── endgame.py │ │ └── externalaccess.py │ ├── __init__.py │ ├── repl_cli.py │ ├── query_orgs.py │ ├── query_cli.py │ ├── query_result.py │ └── argquery_cli.py ├── visualizing │ ├── __init__.py │ ├── graph_writer.py │ ├── cli.py │ └── graphviz_writer.py ├── util │ ├── __init__.py │ ├── debug_print.py │ ├── arns.py │ ├── storage.py │ ├── botocore_tools.py │ └── case_insensitive_dict.py ├── __init__.py ├── analysis │ ├── __init__.py │ ├── finding.py │ ├── report.py │ └── cli.py ├── graphing │ ├── __init__.py │ ├── edge_checker.py │ ├── edge_identification.py │ ├── cross_account_edges.py │ ├── graph_actions.py │ ├── ssm_edges.py │ └── sts_edges.py ├── common │ ├── __init__.py │ ├── policies.py │ ├── groups.py │ ├── edges.py │ └── nodes.py └── __main__.py ├── tests ├── test_graph_checking.py ├── __init__.py ├── test_constructors.py ├── test_edge_identification.py ├── test_admin_identification.py ├── test_local_policy_sim.py ├── test_permissions_boundaries.py └── test_org_trees.py ├── pmapper.py ├── required-permissions.json ├── Identitycentre_README.md ├── NEO4J_README.md ├── CHANGELOG.md ├── setup.py └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | botocore > 1.13 2 | packaging 3 | python-dateutil 4 | pydot 5 | rich 6 | neo4j -------------------------------------------------------------------------------- /examples/example-viz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Fennerr/PMapper/HEAD/examples/example-viz.png -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | include examples/example-viz.png 4 | include examples/example-privesc-only-viz.svg 5 | recursive-exclude * *.pyc 6 | prune tests -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.8-slim-buster 2 | 3 | COPY . /app 4 | RUN apt-get update ; apt-get install -y graphviz 5 | RUN mkdir -p /storage 6 | RUN pip install /app 7 | ENV PMAPPER_STORAGE /storage 8 | 9 | CMD sh 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore Python artifacts 2 | *.swp 3 | *.pyc 4 | 5 | # Ignore image artifacts 6 | *.png 7 | *.svg 8 | *.dot 9 | 10 | # Ignore temp files created by gedit 11 | 12 | *~ 13 | 14 | # Ignore .idea files 15 | *.idea* 16 | 17 | # Ignore coding files 18 | /venv* 19 | 20 | # Ignore vscode 21 | .vscode/ -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/quick-q.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Quick Question 3 | about: Ask a question about PMapper or related concepts 4 | labels: question 5 | assignees: ncc-erik-steringer 6 | --- 7 | 8 | **Question** 9 | 10 | Place your question here. 11 | 12 | **Did the Wiki Have an Answer?** 13 | 14 | If https://github.com/nccgroup/PMapper/wiki does not have an answer, please suggest where to put one. 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Report issues that causes the script to fail to execute 4 | labels: bug 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior, please include information on suspected users/roles that are the source of the issue when possible: 12 | 1. Go to '...' 13 | 2. Click on '....' 14 | 3. Scroll down to '....' 15 | 4. See error 16 | 17 | **Expected behavior** 18 | A clear and concise description of what you expected to happen. 19 | -------------------------------------------------------------------------------- /examples/sample_cf_template.json: -------------------------------------------------------------------------------- 1 | { 2 | "Description": "A sample JSON CloudFormation template for testing against PMapper", 3 | "Resources": { 4 | "ITUser": { 5 | "Type": "AWS::IAM::User", 6 | "Properties": { 7 | "LoginProfile": { 8 | "Password": "Passw0rd" 9 | }, 10 | "Path": "/", 11 | "Tags": [ 12 | { 13 | "Key": "Department", 14 | "Value": "IT" 15 | } 16 | ], 17 | "UserName": "ITUser", 18 | "Policies": [ 19 | { 20 | "PolicyName": "Inline1", 21 | "PolicyDocument": { 22 | "Version": "2012-10-17", 23 | "Statement": [ 24 | { 25 | "Effect": "Allow", 26 | "Action": [ "ec2:*", "iam:PassRole" ], 27 | "Resource": "*" 28 | } 29 | ] 30 | } 31 | } 32 | ] 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /principalmapper/querying/presets/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 2 | # 3 | # Principal Mapper is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU Affero General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # Principal Mapper is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with Principal Mapper. If not, see . 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /principalmapper/visualizing/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 2 | # 3 | # Principal Mapper is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU Affero General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # Principal Mapper is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with Principal Mapper. If not, see . 15 | 16 | """Module for visualizing""" 17 | -------------------------------------------------------------------------------- /principalmapper/querying/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 2 | # 3 | # Principal Mapper is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU Affero General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # Principal Mapper is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with Principal Mapper. If not, see . 15 | 16 | 17 | 18 | """Module for querying""" 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/auth-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Authorization Report 3 | about: Report issues where the PMapper simulator does not correctly replicate the authorization behavior of AWS IAM 4 | labels: bug 5 | --- 6 | 7 | **Brief Description** 8 | A clear and concise description of what the bug is. 9 | 10 | **IAM Action, Resource, and Condition Being Authorized** 11 | The Action, Resource(s), and Condition(s) of the API call being authorized. 12 | 13 | **IAM Policies Attached to Principal** 14 | The IAM Policies attached to the principal making the API call being authorized. If possible, reduce the involved 15 | policies to the bare minimum statement(s) that still reproduce the issue. 16 | 17 | **Expected Behavior** 18 | Whether or not the API call should be authorized. 19 | 20 | **AWS IAM Policy Simulation Result** 21 | If possible, run the request parameters through AWS' IAM Policy Simulator () and report the result. 22 | -------------------------------------------------------------------------------- /principalmapper/util/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 2 | # 3 | # Principal Mapper is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU Affero General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # Principal Mapper is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with Principal Mapper. If not, see . 15 | 16 | 17 | 18 | """Module defining functions used for utility purposes such as loading and saving data""" 19 | -------------------------------------------------------------------------------- /principalmapper/__init__.py: -------------------------------------------------------------------------------- 1 | """Module principalmapper: Python code to dissect and analyze an AWS account's use of IAM""" 2 | 3 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 4 | # 5 | # Principal Mapper is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Principal Mapper is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with Principal Mapper. If not, see . 17 | 18 | __version__ = '1.1.5' 19 | -------------------------------------------------------------------------------- /principalmapper/analysis/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 2 | # 3 | # Principal Mapper is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU Affero General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # Principal Mapper is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with Principal Mapper. If not, see . 15 | 16 | """The Python module containing the code for identifying risks in an AWS account based on Principal Mapper data.""" 17 | -------------------------------------------------------------------------------- /tests/test_graph_checking.py: -------------------------------------------------------------------------------- 1 | """Code for testing code operating on Graph objects""" 2 | 3 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 4 | # 5 | # Principal Mapper is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Principal Mapper is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with Principal Mapper. If not, see . 17 | 18 | import logging 19 | import unittest 20 | 21 | 22 | class GraphCheckingTest(unittest.TestCase): 23 | pass 24 | -------------------------------------------------------------------------------- /principalmapper/graphing/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 2 | # 3 | # Principal Mapper is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU Affero General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # Principal Mapper is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with Principal Mapper. If not, see . 15 | 16 | """Module for graphing code: handles creating Graphs, Nodes, Edges, Policies, and Groups using access to the 17 | AWS account being examined. All edge-identification code resides in here. 18 | """ 19 | -------------------------------------------------------------------------------- /pmapper.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | Wrap around principalmapper/__main__.py 5 | """ 6 | 7 | 8 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 9 | # 10 | # Principal Mapper is free software: you can redistribute it and/or modify 11 | # it under the terms of the GNU Affero General Public License as published by 12 | # the Free Software Foundation, either version 3 of the License, or 13 | # (at your option) any later version. 14 | # 15 | # Principal Mapper is distributed in the hope that it will be useful, 16 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 17 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 | # GNU Affero General Public License for more details. 19 | # 20 | # You should have received a copy of the GNU Affero General Public License 21 | # along with Principal Mapper. If not, see . 22 | 23 | import sys 24 | 25 | from principalmapper.__main__ import main 26 | 27 | if __name__ == '__main__': 28 | sys.exit(main()) 29 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) NCC Group and Erik Steringer 2020. This file is part of Principal Mapper. 2 | # 3 | # Principal Mapper is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU Affero General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # Principal Mapper is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with Principal Mapper. If not, see . 15 | 16 | import logging 17 | import sys 18 | 19 | logging.basicConfig( 20 | stream=sys.stderr, 21 | level=logging.DEBUG, 22 | datefmt='%Y-%m-%d %H:%M:%S%z', 23 | format='%(asctime)s | %(levelname)8s | %(name)s | %(message)s' 24 | ) 25 | -------------------------------------------------------------------------------- /required-permissions.json: -------------------------------------------------------------------------------- 1 | { 2 | "Version": "2012-10-17", 3 | "Statement": [ 4 | { 5 | "Sid": "PMapperPerms", 6 | "Effect": "Allow", 7 | "Action": [ 8 | "iam:List*", 9 | "iam:Get*", 10 | "organizations:List*", 11 | "organizations:Describe*", 12 | "s3:ListAllMyBuckets", 13 | "s3:ListBucket", 14 | "s3:GetBucketPolicy", 15 | "kms:ListKeys", 16 | "kms:GetKeyPolicy", 17 | "sns:ListTopics", 18 | "sns:GetTopicAttributes", 19 | "sqs:ListQueues", 20 | "sqs:GetQueueAttributes", 21 | "secretsmanager:ListSecrets", 22 | "secretsmanager:GetResourcePolicy", 23 | "cloudformation:DescribeStacks", 24 | "lambda:ListFunctions", 25 | "codebuild:ListProjects", 26 | "codebuild:BatchGetProjects", 27 | "autoscaling:DescribeLaunchConfigurations" 28 | ], 29 | "Resource": "*" 30 | } 31 | ] 32 | } -------------------------------------------------------------------------------- /.github/workflows/test_PRs.yml: -------------------------------------------------------------------------------- 1 | # This workflow is designed to run through the process of installing, building, and executing 2 | # basic PMapper unittests against PMapper's supported versions when there's a new PR aiming 3 | # at the "master" branch 4 | 5 | name: "Test Against Pythons" 6 | 7 | on: 8 | pull_request: 9 | branches: [ master ] 10 | workflow_dispatch: 11 | permissions: 12 | actions: read 13 | issues: write 14 | contents: read 15 | discussions: write 16 | 17 | jobs: 18 | build_and_test: 19 | runs-on: ${{ matrix.os }} 20 | strategy: 21 | fail-fast: false 22 | matrix: 23 | os: ["ubuntu-latest", "windows-latest", "macos-latest"] 24 | python-version: ["3.6", "3.10"] 25 | steps: 26 | - name: "Grab Code" 27 | uses: actions/checkout@v2 28 | 29 | - name: "Install Python" 30 | uses: actions/setup-python@v2 31 | with: 32 | python-version: ${{ matrix.python-version }} 33 | 34 | - name: "Install PMapper" 35 | shell: bash 36 | working-directory: ${{ github.workspace }} 37 | run: | 38 | pip install . 39 | pip show principalmapper 40 | 41 | - name: "Run Test Cases" 42 | shell: bash 43 | working-directory: ${{ github.workspace }} 44 | run: | 45 | python -m unittest -v tests/test* 46 | -------------------------------------------------------------------------------- /Identitycentre_README.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | The IdentityCentre PMapper module facilitates the mapping of users and groups within the AWS IAM Identity Centre (IDC) service to their corresponding AWS IAM roles across various AWS accounts within the organization. AWS IAM IDC enables single sign-on (SSO) for employees using their identity provider, such as AzureAD. Users authenticate through their identity provider and are provisioned with SSO access to AWS accounts managed in the IAM IDC. 3 | 4 | This module maps the relationships between IDC SSO users and the IAM roles they have access to in the AWS accounts within the organization. 5 | 6 | # Usage 7 | ## Get Organization ID 8 | To get the organization ID, use the following command: 9 | ```bash 10 | python3 pmapper.py orgs list 11 | ``` 12 | 13 | This command will provide a list of Organization IDs: 14 | ```bash 15 | Organization IDs: 16 | --- 17 | o-v {REDACTED} bo (PMapper Version 1.1.5) 18 | ``` 19 | 20 | ## Map IDC SSO Users to IAM Roles 21 | 22 | To map IDC SSO users to IAM roles for a specific organization, use the following command: 23 | ```bash 24 | python3 pmapper.py --profile {Management AWS Account ID of the Organisation} orgs identitycenter --org {The organization ID} 25 | ``` 26 | 27 | Replace {Management AWS Account ID of the Organisation} with the AWS account ID responsible for managing the organization, and {The organization ID} with the specific organization ID you retrieved in the previous step. -------------------------------------------------------------------------------- /NEO4J_README.md: -------------------------------------------------------------------------------- 1 | ## Setup 2 | 3 | Get Neo4j running: 4 | ``` 5 | docker run --name pmapper --env=NEO4J_AUTH=none --publish=7474:7474 --publish=7687:7687 --volume=$HOME/neo4j/data:/data neo4j:4.4 6 | ``` 7 | 8 | ## Cypher Queries 9 | 10 | Priv Esc Paths: 11 | 12 | You might want to limit the number of edges it searches through by using `[*1..3]` - which will look for 1 to 3 edges between the nodes. Adjust as necessary 13 | An optional edge can be specified by starting at 0 (ie `[*0..]`) 14 | 15 | ```cypher 16 | MATCH path = (start)-[*1..3]->(end {is_admin: true}) 17 | WHERE start.is_admin = false 18 | and not start.arn ends with 'AWSServiceRoleForSSO' 19 | RETURN path 20 | ``` 21 | 22 | Cross-Account Access: 23 | ```cypher 24 | MATCH path = (start:Principal)-[link:CROSS_ACCOUNT_ACCESS]->-[*0..3]->(end:Principal {is_admin: true}) 25 | RETURN start, link, end 26 | ``` 27 | 28 | External Account Access: 29 | ```cypher 30 | MATCH (start:External_Account)-[]->(end:Principal {is_admin: true}) 31 | RETURN * 32 | ``` 33 | 34 | External Account Access to Admin: 35 | ```cypher 36 | MATCH path = (start)-[:EXTERNAL_ACCESS]->(mid)-[:EDGE|CROSS_ACCOUNT_ACCESS*0..]->(end {is_admin: true}) 37 | RETURN path 38 | ``` 39 | 40 | ### Identity Centre 41 | 42 | Access to Admin: 43 | ```cypher 44 | match path = (realstart)-[:MEMBER_OF*0..1]-(start)-[:IDENTITYCENTRE_ACCESS]-()-[*1..3]->(END {is_admin: true}) 45 | return * 46 | ``` 47 | 48 | Admin Access to AWS Account: 49 | ```cypher 50 | MATCH path = (realstart)-[:MEMBER_OF*0..1]-(start)-[:IDENTITYCENTRE_ACCESS]-()-[*1..4]->(END {is_admin: true}) 51 | WHERE END.account_id CONTAINS '{AWS ACCOUNT ID}' 52 | RETURN path 53 | ``` -------------------------------------------------------------------------------- /principalmapper/common/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 2 | # 3 | # Principal Mapper is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU Affero General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # Principal Mapper is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with Principal Mapper. If not, see . 15 | 16 | 17 | """Module defining classes and functions used commonly across Principal Mapper. Importing this package currently gives 18 | the Node, Edge, Graph, Group, and Policy classes, i.e. you can use `from principalmapper.common import Graph`.""" 19 | 20 | from principalmapper.common.nodes import Node 21 | from principalmapper.common.edges import Edge 22 | from principalmapper.common.graphs import Graph 23 | from principalmapper.common.groups import Group 24 | from principalmapper.common.policies import Policy 25 | from principalmapper.common.org_trees import OrganizationAccount, OrganizationNode, OrganizationTree, IdentityStore 26 | 27 | # Put submodules into __all__ for neater interface of principalmapper.common 28 | __all__ = ['Node', 'Edge', 'Graph', 'Group', 'Policy', 'OrganizationAccount', 'OrganizationNode', 'OrganizationTree', 'IdentityStore'] 29 | -------------------------------------------------------------------------------- /principalmapper/util/debug_print.py: -------------------------------------------------------------------------------- 1 | """Code for handling printing to console depending on if debugging is enabled""" 2 | 3 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 4 | # 5 | # Principal Mapper is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Principal Mapper is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with Principal Mapper. If not, see . 17 | 18 | import logging 19 | import sys 20 | 21 | 22 | logger = logging.getLogger(__name__) 23 | 24 | 25 | def dprint(debugging: bool, message: str) -> None: 26 | """DEPRECATED AS OF v1.1.0 DURING LOGGING OVERHAUL. 27 | 28 | Prints message to stderr if debugging, newline is automatically appended""" 29 | logger.warning('The `dprint` function is deprecated, and should not be used.') 30 | if debugging: 31 | sys.stderr.write(message) 32 | sys.stderr.write("\n") 33 | 34 | 35 | def dwrite(debugging: bool, message: str) -> None: 36 | """DEPRECATED AS OF v1.1.0 DURING LOGGING OVERHAUL. 37 | 38 | Writes message to stderr if debugging (no newline at the end)""" 39 | logger.warning('The `dwrite` function is deprecated, and should not be used.') 40 | if debugging: 41 | sys.stderr.write(message) 42 | -------------------------------------------------------------------------------- /principalmapper/analysis/finding.py: -------------------------------------------------------------------------------- 1 | """Python code for putting together information about a finding generated by Principal Mapper data.""" 2 | 3 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 4 | # 5 | # Principal Mapper is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Principal Mapper is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with Principal Mapper. If not, see . 17 | 18 | 19 | class Finding: 20 | """Finding holds information about a potential risk in an AWS account, as identified by Principal Mapper.""" 21 | 22 | def __init__(self, title: str, severity: str, impact: str, description: str, recommendation: str): 23 | self.title = title 24 | self.severity = severity 25 | self.impact = impact 26 | self.description = description 27 | self.recommendation = recommendation 28 | 29 | def to_dictionary(self) -> dict: 30 | """Returns a dictionary representation of this Finding.""" 31 | return { 32 | 'title': self.title, 33 | 'severity': self.severity, 34 | 'impact': self.impact, 35 | 'description': self.description, 36 | 'recommendation': self.recommendation 37 | } 38 | -------------------------------------------------------------------------------- /principalmapper/analysis/report.py: -------------------------------------------------------------------------------- 1 | """Python code for putting together a report of findings. Holds the main Report class.""" 2 | 3 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 4 | # 5 | # Principal Mapper is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Principal Mapper is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with Principal Mapper. If not, see . 17 | 18 | import datetime as dt 19 | from typing import List 20 | 21 | from principalmapper.analysis.finding import Finding 22 | 23 | 24 | class Report: 25 | """FindingsReport holds information about findings, and where the findings were pulled from. It also provides a 26 | utility function to convert the contents of the report to a dictionary object. 27 | """ 28 | 29 | def __init__(self, account: str, date_and_time: dt.datetime, findings: List[Finding], source: str): 30 | self.account = account 31 | self.date_and_time = date_and_time 32 | self.findings = findings 33 | self.source = source 34 | 35 | def to_dictionary(self) -> dict: 36 | """Produces a dictionary representing this Report's contents.""" 37 | return { 38 | 'account': self.account, 39 | 'date_and_time': self.date_and_time.isoformat(), 40 | 'findings': [x.to_dictionary() for x in self.findings], 41 | 'source': self.source 42 | } 43 | -------------------------------------------------------------------------------- /principalmapper/querying/repl_cli.py: -------------------------------------------------------------------------------- 1 | """Code to implement the CLI interface to the REPL component of Principal Mapper""" 2 | 3 | # Copyright (c) NCC Group and Erik Steringer 2020. This file is part of Principal Mapper. 4 | # 5 | # Principal Mapper is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Principal Mapper is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with Principal Mapper. If not, see . 17 | 18 | from argparse import ArgumentParser, Namespace 19 | 20 | from principalmapper.graphing import graph_actions 21 | from principalmapper.querying import repl 22 | from principalmapper.util import botocore_tools 23 | 24 | 25 | def provide_arguments(parser: ArgumentParser): 26 | """Given a parser object (which should be a subparser), add arguments to provide a CLI interface to the 27 | repl component of Principal Mapper. 28 | """ 29 | pass 30 | 31 | 32 | def process_arguments(parsed_args: Namespace): 33 | """Given a namespace object generated from parsing args, perform the appropriate tasks. Returns an int 34 | matching expectations set by /usr/include/sysexits.h for command-line utilities.""" 35 | 36 | if parsed_args.account is None: 37 | session = botocore_tools.get_session(parsed_args.profile) 38 | else: 39 | session = None 40 | graph = graph_actions.get_existing_graph(session, parsed_args.account) 41 | 42 | repl_obj = repl.PMapperREPL(graph) 43 | repl_obj.begin_repl() 44 | 45 | return 0 46 | -------------------------------------------------------------------------------- /principalmapper/analysis/cli.py: -------------------------------------------------------------------------------- 1 | """Code to implement the CLI interface to the analysis component of Principal Mapper""" 2 | 3 | # Copyright (c) NCC Group and Erik Steringer 2020. This file is part of Principal Mapper. 4 | # 5 | # Principal Mapper is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Principal Mapper is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with Principal Mapper. If not, see . 17 | 18 | from argparse import ArgumentParser, Namespace 19 | 20 | from principalmapper.analysis import find_risks 21 | from principalmapper.graphing import graph_actions 22 | from principalmapper.util import botocore_tools 23 | 24 | 25 | def provide_arguments(parser: ArgumentParser): 26 | """Given a parser object (which should be a subparser), add arguments to provide a CLI interface to the 27 | analysis component of Principal Mapper. 28 | """ 29 | parser.add_argument( 30 | '--output-type', 31 | default='text', 32 | choices=['text', 'json'], 33 | help='The type of output for identified issues.' 34 | ) 35 | 36 | 37 | def process_arguments(parsed_args: Namespace) -> int: 38 | """Given a namespace object generated from parsing args, perform the appropriate tasks. Returns an int 39 | matching expectations set by /usr/include/sysexits.h for command-line utilities.""" 40 | 41 | if parsed_args.account is None: 42 | session = botocore_tools.get_session(parsed_args.profile) 43 | else: 44 | session = None 45 | graph = graph_actions.get_existing_graph(session, parsed_args.account) 46 | 47 | find_risks.gen_findings_and_print(graph, parsed_args.output_type) 48 | 49 | return 0 50 | -------------------------------------------------------------------------------- /examples/gather_and_analyze.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) NCC Group and Erik Steringer 2021. This file is part of Principal Mapper. 2 | # 3 | # Principal Mapper is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU Affero General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # Principal Mapper is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with Principal Mapper. If not, see . 15 | 16 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 17 | # 18 | # 19 | # 20 | """ 21 | The following is an example of how to use Principal Mapper like a library in a script. This code pulls graph data for 22 | an AWS account, runs analysis on it, then prints the output of the analysis. The graph data is not stored on-disk. 23 | 24 | """ 25 | 26 | import argparse 27 | 28 | from principalmapper.analysis import find_risks 29 | from principalmapper.graphing import graph_actions 30 | from principalmapper.graphing.edge_identification import checker_map 31 | from principalmapper.util import botocore_tools 32 | 33 | 34 | def main(): 35 | """Body of the script.""" 36 | 37 | # Handle input args --profile and --format 38 | parser = argparse.ArgumentParser() 39 | parser.add_argument('--profile') 40 | parser.add_argument('--format', default='text', choices=['text', 'json']) 41 | parsed_args = parser.parse_args() 42 | 43 | # Generate the graph (such as with `pmapper graph create`) 44 | session = botocore_tools.get_session(parsed_args.profile) 45 | graph_obj = graph_actions.create_new_graph(session, checker_map.keys()) 46 | 47 | # Print out identified findings (such as with `pmapper analysis`) 48 | find_risks.gen_findings_and_print(graph_obj, parsed_args.format) 49 | 50 | 51 | if __name__ == '__main__': 52 | main() 53 | -------------------------------------------------------------------------------- /principalmapper/common/policies.py: -------------------------------------------------------------------------------- 1 | """Python module containing the Policy class and any Policy-specific utility functions (currently none).""" 2 | 3 | 4 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 5 | # 6 | # Principal Mapper is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Affero General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # Principal Mapper is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU Affero General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU Affero General Public License 17 | # along with Principal Mapper. If not, see . 18 | 19 | class Policy(object): 20 | """The basic Policy object: tracks data about the IAM Policy this represents. This includes who the policy 21 | is attached to (arn is the IAM User/Role for inline, policy ARN otherwose), what its name is (inline), 22 | and the contents of the policy (in dictionary form).""" 23 | 24 | def __init__(self, arn: str, name: str, policy_doc: dict): 25 | """Constructor. 26 | 27 | Expects an ARN with either :user/, :role/, :group/, or :policy/ in it (tracked as managed or inline this way) 28 | Expects a dictionary for the policy document parameter, so you must parse the JSON beforehand 29 | """ 30 | if arn is None: 31 | raise ValueError('The parameter arn must be a string with an ARN for a principal, policy, or resource') 32 | if policy_doc is None or not isinstance(policy_doc, dict): 33 | raise ValueError('Policy objects must be constructed with a dictionary policy_doc parameter') 34 | 35 | self.arn = arn 36 | self.name = name 37 | self.policy_doc = policy_doc 38 | 39 | def to_dictionary(self) -> dict: 40 | """Returns a dictionary representation of this object for storage""" 41 | return { 42 | 'arn': self.arn, 43 | 'name': self.name, 44 | 'policy_doc': self.policy_doc 45 | } 46 | -------------------------------------------------------------------------------- /principalmapper/common/groups.py: -------------------------------------------------------------------------------- 1 | """Python module containing the Group class and any Group-specific utility functions (currently none).""" 2 | 3 | 4 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 5 | # 6 | # Principal Mapper is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Affero General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # Principal Mapper is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU Affero General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU Affero General Public License 17 | # along with Principal Mapper. If not, see . 18 | 19 | from typing import List, Optional 20 | 21 | from principalmapper.common.policies import Policy 22 | from principalmapper.util import arns 23 | 24 | 25 | class Group(object): 26 | """The basic Group object: Contains the ARN and attached IAM policies (inline and attached) of the AWS IAM group 27 | that the object represents. 28 | """ 29 | 30 | def __init__(self, arn: str, attached_policies: Optional[List[Policy]], display_name: Optional[str] = None): 31 | """Constructor""" 32 | if arn is None or not arns.get_resource(arn).startswith('group/'): 33 | raise ValueError('Group objects must be constructed with a valid ARN for a group') 34 | self.arn = arn 35 | if display_name is None: 36 | self.display_name = "" 37 | else: 38 | self.display_name = display_name 39 | 40 | if attached_policies is None: 41 | self.attached_policies = [] 42 | else: 43 | self.attached_policies = attached_policies 44 | 45 | def to_dictionary(self) -> dict: 46 | """Returns a dictionary representation of this object for storage""" 47 | return { 48 | 'arn': self.arn, 49 | 'attached_policies': [{'arn': policy.arn, 'name': policy.name} for policy in self.attached_policies], 50 | 'display_name': self.display_name 51 | } 52 | -------------------------------------------------------------------------------- /principalmapper/graphing/edge_checker.py: -------------------------------------------------------------------------------- 1 | """Holds the base object EdgeChecker to be implemented and used in other classes that identify edges.""" 2 | 3 | 4 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 5 | # 6 | # Principal Mapper is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Affero General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # Principal Mapper is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU Affero General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU Affero General Public License 17 | # along with Principal Mapper. If not, see . 18 | 19 | import io 20 | import os 21 | from typing import List, Optional 22 | 23 | import botocore.session 24 | 25 | from principalmapper.common import Edge, Node 26 | 27 | 28 | class EdgeChecker(object): 29 | """Base class for all edge-identifying classes.""" 30 | 31 | def __init__(self, session: botocore.session.Session): 32 | self.session = session 33 | 34 | def return_edges(self, nodes: List[Node], region_allow_list: Optional[List[str]] = None, 35 | region_deny_list: Optional[List[str]] = None, scps: Optional[List[List[dict]]] = None, 36 | client_args_map: Optional[dict] = None) -> List[Edge]: 37 | """Subclasses shall override this method. Given a list of nodes, the EdgeChecker should be able to use its session 38 | object in order to make clients and call the AWS API to resolve information about the account. Then, 39 | with this information, it should return a list of edges between the passed nodes. 40 | 41 | The region allow/deny lists are mutually-exclusive (i.e. at least one of which has the value None) lists of 42 | allowed/denied regions to pull data from. 43 | """ 44 | raise NotImplementedError('The return_edges method should not be called from EdgeChecker, but rather from an ' 45 | 'object that subclasses EdgeChecker') 46 | -------------------------------------------------------------------------------- /tests/test_constructors.py: -------------------------------------------------------------------------------- 1 | """Code for testing the constructors of graphs, nodes, edges, policies, and groups""" 2 | 3 | 4 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 5 | # 6 | # Principal Mapper is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Affero General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # Principal Mapper is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU Affero General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU Affero General Public License 17 | # along with Principal Mapper. If not, see . 18 | 19 | import logging 20 | import unittest 21 | 22 | from principalmapper.common.graphs import Graph 23 | from principalmapper.common.nodes import Node 24 | 25 | 26 | class ConstructorTest(unittest.TestCase): 27 | def test_graphs(self): 28 | with self.assertRaises(ValueError): 29 | Graph(nodes=None, edges=[], policies=[], groups=[]) 30 | with self.assertRaises(ValueError): 31 | Graph(nodes=[], edges=None, policies=[], groups=[]) 32 | with self.assertRaises(ValueError): 33 | Graph(nodes=[], edges=[], policies=None, groups=[]) 34 | with self.assertRaises(ValueError): 35 | Graph(nodes=[], edges=[], policies=[], groups=None) 36 | 37 | def test_nodes(self): 38 | with self.assertRaises(ValueError): 39 | Node(arn='arn:aws:iam::000000000000:group/notauser', id_value='AIDA00000000000000000', attached_policies=[], 40 | group_memberships=[], trust_policy=None, instance_profile=None, access_keys=0, 41 | active_password=False, is_admin=False, permissions_boundary=None, has_mfa=False, tags={}) 42 | try: 43 | Node(arn='arn:aws:iam::000000000000:user/auser', id_value='AIDA00000000000000001', attached_policies=[], 44 | group_memberships=[], trust_policy=None, instance_profile=None, access_keys=0, 45 | active_password=False, is_admin=False, permissions_boundary=None, has_mfa=False, tags={}) 46 | except Exception as ex: 47 | self.fail('Unexpected error: ' + str(ex)) 48 | -------------------------------------------------------------------------------- /principalmapper/visualizing/graph_writer.py: -------------------------------------------------------------------------------- 1 | """Code to write Graph data to various output formats.""" 2 | 3 | 4 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 5 | # 6 | # Principal Mapper is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Affero General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # Principal Mapper is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU Affero General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU Affero General Public License 17 | # along with Principal Mapper. If not, see . 18 | 19 | import pydot 20 | from typing import List, Optional 21 | 22 | from principalmapper.common import Graph, Node, Edge 23 | from principalmapper.querying.presets.privesc import can_privesc 24 | from principalmapper.visualizing import graphml_writer, graphviz_writer 25 | 26 | 27 | def handle_request(graph: Graph, path: str, file_format: str, with_services: Optional[bool] = False) -> None: 28 | """Meat of the graph_writer.py module, writes graph data in a given file-format to the given path.""" 29 | 30 | # adding extra branch to handle new GraphML format 31 | if file_format == 'graphml': 32 | return graphml_writer.write_standard_graphml(graph, path, with_services) 33 | 34 | elif file_format in ('svg', 'png', 'dot'): 35 | return graphviz_writer.write_standard_graphviz(graph, path, file_format, with_services) 36 | 37 | else: 38 | raise ValueError('Unexpected value for parameter `file_format`') 39 | 40 | 41 | def draw_privesc_paths(graph: Graph, path: str, file_format: str) -> None: 42 | """Draws a graph using Graphviz (dot) with a specific set of nodes and edges to highlight admins and privilege 43 | escalation paths.""" 44 | 45 | # adding extra branch to handle new GraphML format 46 | if file_format == 'graphml': 47 | return graphml_writer.write_privesc_graphml(graph, path) 48 | 49 | elif file_format in ('svg', 'png', 'dot'): 50 | return graphviz_writer.write_privesc_graphviz(graph, path, file_format) 51 | 52 | else: 53 | raise ValueError('Unexpected value for parameter `file_format`') 54 | 55 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to the project shall be documented in this file. 4 | 5 | ## 1.1.0 6 | 7 | ### Added 8 | 9 | * Added support for SCPs, Session Policies, Permission Boundaries, and Resource Policies 10 | * Added caching for S3 bucket policies, KMS key policies, SNS topic policies, SQS queue policies 11 | * Added support for obtaining AWS Organizations data (OrganizationTree objects) and the `orgs` subcommand 12 | * Implemented logging with `logging` module for the library 13 | * Added new findings for `analysis` submodule/command 14 | * Added new output format for `visualization`: GraphML 15 | * Added support for the `PMAPPER_STORAGE` environment variable to set a custom location where Graph/OrganizationTree data is stored 16 | * Added a starter Dockerfile (should work with modifications to add creds via env vars or from an EC2 instance with an instance profile assigned) 17 | * Various bugfixes and improvements 18 | 19 | ### Changed 20 | 21 | * Separated out `graph` subcommand into separate subsubcommands (`graph create` rather than `graph --create`) 22 | 23 | ### Removed 24 | 25 | * (Library code) Most instances of `dprint`, `debug` params, `output` params except for `write_*` functions. Replaced `write_*` functions with `print_*` functions. 26 | * (Library code) Dropping support for certain code in `principalmapper.gathering`: `get_unfilled_*`, `get_policies_and_fill_out`. You should use `get_nodes_groups_and_policies` instead. 27 | 28 | ### Special Thanks 29 | 30 | * @yehudacohen 31 | * @pr454nn4kum4r 32 | * @kmcquade 33 | * @danieladams456 34 | * All my colleagues at NCC Group 35 | * Rami McCarthy 36 | 37 | ## 1.0.1 38 | 39 | ### Added 40 | 41 | * Added support for OpenBSD standard storage location 42 | * Various bugfixes and improvements 43 | 44 | ### Special Thanks 45 | 46 | * @ancwatson 47 | * @buzzdeee 48 | 49 | ## 1.0.0 50 | 51 | ### Added 52 | 53 | * Implemented a new graph storage solution 54 | * Implemented full local policy simulation and replaced all calls to AWS IAM simulation APIs 55 | * Implemented a new querying interface: `argquery` 56 | * Implemented a REPL 57 | * Added the `analysis` module and command 58 | * Docstrings and type-hints 59 | * Full Python 3.5+ support 60 | 61 | ### Changed 62 | 63 | * Library code is now under `principalmapper` and not `principalmap` 64 | 65 | ### Removed 66 | 67 | * Support for Python 2.X completely dropped 68 | * Support for calling AWS IAM's simulation APIs completely dropped 69 | * Graph generated by previous versions are not compatible with v1.0.0 70 | 71 | ### Special Thanks 72 | 73 | * All my colleagues at NCC Group for their support and suggestions 74 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """Code for installing the Principal Mapper library and script.""" 4 | 5 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 6 | # 7 | # Principal Mapper is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Principal Mapper is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with Principal Mapper. If not, see . 19 | 20 | from setuptools import setup, find_packages 21 | import principalmapper 22 | 23 | with open("README.md", "r") as fh: 24 | long_description = fh.read() 25 | 26 | setup( 27 | name='principalmapper', 28 | version=principalmapper.__version__, 29 | description='A Python script and library for analyzing an AWS account\'s use of IAM.', 30 | long_description=long_description, 31 | long_description_content_type='text/markdown', 32 | license='AGPLv3', 33 | url='https://github.com/nccgroup/PMapper', 34 | author='Erik Steringer', 35 | author_email='erik.steringer@nccgroup.com', 36 | scripts=[], 37 | include_package_data=True, 38 | packages=find_packages(exclude=("tests", )), 39 | package_data={}, 40 | python_requires='>=3.5, <4', # assume Python 4 will break 41 | install_requires=['botocore', 'packaging', 'python-dateutil', 'pydot'], 42 | entry_points={ 43 | 'console_scripts': [ 44 | 'pmapper = principalmapper.__main__:main' 45 | ] 46 | }, 47 | classifiers=[ 48 | 'Environment :: Console', 49 | 'Intended Audience :: Developers', 50 | 'Intended Audience :: Information Technology', 51 | 'Intended Audience :: System Administrators', 52 | 'License :: OSI Approved :: GNU Affero General Public License v3', 53 | 'Natural Language :: English', 54 | 'Programming Language :: Python :: 3', 55 | 'Programming Language :: Python :: 3.5', 56 | 'Programming Language :: Python :: 3.6', 57 | 'Programming Language :: Python :: 3.7', 58 | 'Programming Language :: Python :: 3.8', 59 | 'Programming Language :: Python :: 3.9', 60 | 'Topic :: Security' 61 | ], 62 | keywords=[ 63 | 'AWS', 'IAM', 'Security', 'PMapper', 'principalmapper', 'Principal Mapper', 'NCC Group' 64 | ], 65 | zip_safe=False 66 | ) 67 | -------------------------------------------------------------------------------- /tests/test_edge_identification.py: -------------------------------------------------------------------------------- 1 | """Test code for the edge-identifying functions and classes""" 2 | 3 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 4 | # 5 | # Principal Mapper is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Principal Mapper is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with Principal Mapper. If not, see . 17 | 18 | import logging 19 | import unittest 20 | 21 | from principalmapper.common.graphs import Graph 22 | from principalmapper.common.nodes import Node 23 | from principalmapper.graphing.edge_identification import obtain_edges 24 | from principalmapper.querying.query_utils import get_search_list, is_connected 25 | from tests.build_test_graphs import build_playground_graph 26 | 27 | 28 | class TestEdgeIdentification(unittest.TestCase): 29 | 30 | def test_playground_assume_role(self): 31 | graph = build_playground_graph() 32 | jump_user_node = graph.get_node_by_searchable_name('user/jumpuser') 33 | assumable_s3_role_node = graph.get_node_by_searchable_name('role/s3_access_role') 34 | assumable_s3_role_node_alt = graph.get_node_by_searchable_name('role/s3_access_role_alt') 35 | nonassumable_role_node = graph.get_node_by_searchable_name('role/external_s3_access_role') 36 | self.assertTrue(is_connected(graph, jump_user_node, assumable_s3_role_node)) 37 | self.assertTrue(is_connected(graph, jump_user_node, assumable_s3_role_node_alt)) 38 | self.assertFalse(is_connected(graph, jump_user_node, nonassumable_role_node)) 39 | 40 | def test_admin_access(self): 41 | graph = build_playground_graph() 42 | admin_user_node = graph.get_node_by_searchable_name('user/admin') 43 | jump_user = graph.get_node_by_searchable_name('user/jumpuser') 44 | other_jump_user = graph.get_node_by_searchable_name('user/some_other_jumpuser') 45 | other_assumable_role = graph.get_node_by_searchable_name('role/somerole') 46 | nonassumable_role_node = graph.get_node_by_searchable_name('role/external_s3_access_role') 47 | self.assertTrue(is_connected(graph, admin_user_node, jump_user)) 48 | self.assertTrue(is_connected(graph, admin_user_node, nonassumable_role_node)) 49 | self.assertTrue(is_connected(graph, other_jump_user, other_assumable_role)) 50 | -------------------------------------------------------------------------------- /principalmapper/util/arns.py: -------------------------------------------------------------------------------- 1 | """Utility code for parsing and dealing with ARNs. 2 | 3 | Documentation: https://docs.aws.amazon.com/general/latest/gr/aws-arns-and-namespaces.html 4 | 5 | All functions within assume a valid ARN is passed. 6 | 7 | ARN Format: 8 | arn::::: 9 | """ 10 | 11 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 12 | # 13 | # Principal Mapper is free software: you can redistribute it and/or modify 14 | # it under the terms of the GNU Affero General Public License as published by 15 | # the Free Software Foundation, either version 3 of the License, or 16 | # (at your option) any later version. 17 | # 18 | # Principal Mapper is distributed in the hope that it will be useful, 19 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 20 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 21 | # GNU Affero General Public License for more details. 22 | # 23 | # You should have received a copy of the GNU Affero General Public License 24 | # along with Principal Mapper. If not, see . 25 | 26 | import re 27 | 28 | def is_valid_aws_account_id(string): 29 | pattern = r'^\d{12}$' 30 | return bool(re.match(pattern, string)) 31 | 32 | def get_partition(arn: str): 33 | """Returns the partition from a string ARN.""" 34 | return arn.split(':')[1] 35 | 36 | 37 | def get_service(arn: str): 38 | """Returns the service from a string ARN.""" 39 | return arn.split(':')[2] 40 | 41 | 42 | def get_region(arn: str): 43 | """Returns the region from a string ARN.""" 44 | return arn.split(':')[3] 45 | 46 | 47 | def get_account_id(arn: str): 48 | """Returns the account ID from a string ARN.""" 49 | return arn.split(':')[4] 50 | 51 | 52 | def get_resource(arn: str): 53 | """Returns the resource (trailing part) from a string ARN. Note that we're splitting on colons, so we have to 54 | join with colons in case the trailing part uses colon-separators instead of forward-slashes. 55 | """ 56 | return ':'.join(arn.split(':')[5:]) 57 | 58 | def get_resource_name(arn: str): 59 | """Returns the resource (trailing part) from a string ARN. Note that we're splitting on colons, so we have to 60 | join with colons in case the trailing part uses colon-separators instead of forward-slashes. 61 | Same for the split on forward-flashes 62 | """ 63 | resource_part = ':'.join(arn.split(':')[5:]) 64 | resournce_name = '/'.join(resource_part.split('/')[1:]) 65 | return resournce_name 66 | 67 | 68 | def validate_arn(arn: str) -> bool: 69 | """Returns true if the provided ARN appears to follow the expected structure of an ARN.""" 70 | arn_arr = arn.split(':') 71 | if len(arn_arr) < 6: 72 | return False 73 | if arn_arr[0] != 'arn': 74 | return False 75 | return True 76 | -------------------------------------------------------------------------------- /principalmapper/common/edges.py: -------------------------------------------------------------------------------- 1 | """Python module containing the basic Edge class, as well as any utility functions (currently none).""" 2 | 3 | 4 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 5 | # 6 | # Principal Mapper is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Affero General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # Principal Mapper is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU Affero General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU Affero General Public License 17 | # along with Principal Mapper. If not, see . 18 | 19 | from principalmapper.util import arns 20 | 21 | 22 | class Edge(object): 23 | """The Edge object: contains a source and destination Node object, as well as a string that explains how 24 | the source Node is able to access the destination Node. 25 | """ 26 | 27 | def __init__(self, source, destination, reason: str, short_reason: str): 28 | """Constructor""" 29 | if source is None: 30 | raise ValueError('Edges must have a source Node object') 31 | if destination is None: 32 | raise ValueError('Edges must have a destination Node object') 33 | if reason is None: 34 | raise ValueError('Edges must be constructed with a reason parameter (str)') 35 | if short_reason is None: 36 | raise ValueError('Edges must be constructed with a short_reason parameter (str)') 37 | 38 | self.source = source 39 | self.destination = destination 40 | self.reason = reason 41 | self.short_reason = short_reason 42 | 43 | def describe_edge(self) -> str: 44 | """Returns a human-readable string explaining the edge""" 45 | return "{} {} {}".format( 46 | self.source.searchable_name(), 47 | self.reason, 48 | self.destination.searchable_name() 49 | ) 50 | 51 | def to_dictionary(self) -> dict: 52 | """Returns a dictionary representation of this object for storage""" 53 | return { 54 | 'source': self.source.arn, 55 | 'destination': self.destination.arn, 56 | 'reason': self.reason, 57 | 'short_reason': self.short_reason 58 | } 59 | 60 | def to_dictionary_no_objects(self) -> dict: 61 | """Returns a dictionary representation of this object for storage""" 62 | return { 63 | 'source': self.source, 64 | 'destination': self.destination, 65 | 'reason': self.reason, 66 | 'short_reason': self.short_reason 67 | } 68 | -------------------------------------------------------------------------------- /principalmapper/visualizing/cli.py: -------------------------------------------------------------------------------- 1 | """Code to implement the CLI interface to the visualization component of Principal Mapper""" 2 | 3 | # Copyright (c) NCC Group and Erik Steringer 2020. This file is part of Principal Mapper. 4 | # 5 | # Principal Mapper is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Principal Mapper is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with Principal Mapper. If not, see . 17 | 18 | from argparse import ArgumentParser, Namespace 19 | 20 | from principalmapper.graphing import graph_actions 21 | from principalmapper.util import botocore_tools 22 | from principalmapper.visualizing import graph_writer 23 | 24 | 25 | def provide_arguments(parser: ArgumentParser): 26 | """Given a parser object (which should be a subparser), add arguments to provide a CLI interface to the 27 | visualization component of Principal Mapper. 28 | """ 29 | parser.add_argument( 30 | '--filetype', 31 | default='svg', 32 | choices=['svg', 'png', 'dot', 'graphml'], 33 | help='The (lowercase) filetype to output the image as.' 34 | ) 35 | parser.add_argument( 36 | '--only-privesc', 37 | help='Generates an image file representing an AWS account.', 38 | action='store_true' 39 | ) 40 | parser.add_argument( 41 | '--with-services', 42 | help='Includes services with access to Roles in the AWS account visualization', 43 | action='store_true' 44 | ) 45 | 46 | 47 | def process_arguments(parsed_args: Namespace): 48 | """Given a namespace object generated from parsing args, perform the appropriate tasks. Returns an int 49 | matching expectations set by /usr/include/sysexits.h for command-line utilities.""" 50 | 51 | if parsed_args.account is None: 52 | session = botocore_tools.get_session(parsed_args.profile) 53 | else: 54 | session = None 55 | graph = graph_actions.get_existing_graph(session, parsed_args.account) 56 | 57 | if parsed_args.only_privesc: 58 | filepath = './{}-privesc-risks.{}'.format(graph.metadata['account_id'], parsed_args.filetype) 59 | graph_writer.draw_privesc_paths(graph, filepath, parsed_args.filetype) 60 | else: 61 | # create file 62 | filepath = './{}.{}'.format(graph.metadata['account_id'], parsed_args.filetype) 63 | graph_writer.handle_request(graph, filepath, parsed_args.filetype, parsed_args.with_services) 64 | 65 | print('Created file {}'.format(filepath)) 66 | 67 | return 0 68 | -------------------------------------------------------------------------------- /principalmapper/util/storage.py: -------------------------------------------------------------------------------- 1 | """Code for handling the process of storing or retrieving data to/from disk""" 2 | 3 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 4 | # 5 | # Principal Mapper is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Principal Mapper is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with Principal Mapper. If not, see . 17 | 18 | import os 19 | import os.path 20 | import sys 21 | 22 | 23 | def get_storage_root(): 24 | """Locates and returns a path to the storage root, depending on OS. If the path does not exist yet, it is 25 | created. 26 | 27 | First, it checks for the environment variable PMAPPER_STORAGE and uses that if set. Then, it goes for 28 | per-platform locations recommended by: 29 | https://stackoverflow.com/questions/3373948/equivalents-of-xdg-config-home-and-xdg-data-home-on-mac-os-x 30 | """ 31 | platform = sys.platform 32 | pmapper_env_var = os.getenv('PMAPPER_STORAGE') 33 | result = None 34 | if pmapper_env_var is not None: 35 | result = pmapper_env_var 36 | elif platform == 'win32' or platform == 'cygwin': 37 | # Windows: file root at %APPDATA%\principalmapper\ 38 | appdatadir = os.getenv('APPDATA') 39 | if appdatadir is None: 40 | raise ValueError('%APPDATA% was unexpectedly not set') 41 | result = os.path.join(appdatadir, 'principalmapper') 42 | elif platform == 'linux' or platform == 'freebsd' or platform.startswith('openbsd'): 43 | # Linux/FreeBSD: follow XDG convention: $XDG_DATA_HOME/principalmapper/ 44 | # if $XDG_DATA_HOME isn't set, default to ~/.local/share/principalmapper/ 45 | appdatadir = os.getenv('XDG_DATA_HOME') 46 | if appdatadir is None: 47 | appdatadir = os.path.join(os.path.expanduser('~'), '.local', 'share') 48 | result = os.path.join(appdatadir, 'principalmapper') 49 | elif platform == 'darwin': 50 | # MacOS: follow MacOS convention: ~/Library/Application Support/com.nccgroup.principalmapper/ 51 | appdatadir = os.path.join(os.path.expanduser('~'), 'Library', 'Application Support') 52 | result = os.path.join(appdatadir, 'com.nccgroup.principalmapper') 53 | if not os.path.exists(result): 54 | os.makedirs(result, 0o700) 55 | return result 56 | 57 | 58 | def get_default_graph_path(account_or_org: str): 59 | """Returns a path to a given account or organization by the provided string.""" 60 | return os.path.join(get_storage_root(), account_or_org) 61 | -------------------------------------------------------------------------------- /principalmapper/querying/presets/clusters.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) NCC Group and Erik Steringer 2020. This file is part of Principal Mapper. 2 | # 3 | # Principal Mapper is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU Affero General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # Principal Mapper is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with Principal Mapper. If not, see . 15 | 16 | import io 17 | import os 18 | from typing import List, Dict 19 | 20 | from principalmapper.common import Node, Graph 21 | from principalmapper.querying.presets.connected import is_connected 22 | 23 | 24 | def handle_preset_query(graph: Graph, tokens: List[str], skip_admins: bool = False) -> None: 25 | """Handles a human-readable query that's been chunked into tokens, and prints the result.""" 26 | 27 | tag_name_target = tokens[2] 28 | clusters = generate_clusters(graph, tag_name_target) 29 | 30 | print('# Clusters identified on key {}'.format(tag_name_target)) 31 | for k in clusters.keys(): 32 | if k is None: 33 | continue 34 | print('{}:'.format(k)) 35 | for n in clusters[k]: 36 | print(' {}'.format(n.searchable_name())) 37 | 38 | print() 39 | print('# Boundaries crossed on key {}'.format(tag_name_target)) 40 | for source in clusters.keys(): 41 | if source is None: 42 | continue 43 | 44 | for destination in clusters.keys(): 45 | if destination is None or source == destination: 46 | continue 47 | 48 | for src_node in clusters[source]: 49 | for dst_node in clusters[destination]: 50 | connected, path = is_connected(graph, src_node, dst_node) 51 | if connected: 52 | print('{} can cross boundaries and access {}'.format( 53 | src_node.searchable_name(), dst_node.searchable_name() 54 | )) 55 | for edge in path: 56 | print(' {}'.format(edge.describe_edge())) 57 | 58 | 59 | def generate_clusters(graph: Graph, tag_name_target: str) -> Dict[str, List[Node]]: 60 | """Given a graph, and a key of a tag to work from, group up all the nodes using those tags.""" 61 | 62 | result = {} 63 | for node in graph.nodes: 64 | if tag_name_target in node.tags: 65 | value = node.tags[tag_name_target] 66 | else: 67 | value = None 68 | 69 | if value not in result: 70 | result[value] = [node] 71 | else: 72 | result[value].append(node) 73 | 74 | return result 75 | -------------------------------------------------------------------------------- /principalmapper/graphing/edge_identification.py: -------------------------------------------------------------------------------- 1 | """Code to coordinate identifying edges between principals in an AWS account""" 2 | 3 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 4 | # 5 | # Principal Mapper is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Principal Mapper is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with Principal Mapper. If not, see . 17 | 18 | import logging 19 | from typing import List, Optional 20 | 21 | import botocore.session 22 | 23 | from principalmapper.common import Edge, Node 24 | from principalmapper.graphing.autoscaling_edges import AutoScalingEdgeChecker 25 | from principalmapper.graphing.cloudformation_edges import CloudFormationEdgeChecker 26 | from principalmapper.graphing.codebuild_edges import CodeBuildEdgeChecker 27 | from principalmapper.graphing.ec2_edges import EC2EdgeChecker 28 | from principalmapper.graphing.iam_edges import IAMEdgeChecker 29 | from principalmapper.graphing.lambda_edges import LambdaEdgeChecker 30 | from principalmapper.graphing.sagemaker_edges import SageMakerEdgeChecker 31 | from principalmapper.graphing.ssm_edges import SSMEdgeChecker 32 | from principalmapper.graphing.sts_edges import STSEdgeChecker 33 | 34 | 35 | logger = logging.getLogger(__name__) 36 | 37 | 38 | # Externally referable dictionary with all the supported edge-checking types 39 | checker_map = { 40 | 'autoscaling': AutoScalingEdgeChecker, 41 | 'cloudformation': CloudFormationEdgeChecker, 42 | 'codebuild': CodeBuildEdgeChecker, 43 | 'ec2': EC2EdgeChecker, 44 | 'iam': IAMEdgeChecker, 45 | 'lambda': LambdaEdgeChecker, 46 | 'sagemaker': SageMakerEdgeChecker, 47 | 'ssm': SSMEdgeChecker, 48 | 'sts': STSEdgeChecker 49 | } 50 | 51 | 52 | def obtain_edges(session: Optional[botocore.session.Session], checker_list: List[str], nodes: List[Node], 53 | region_allow_list: Optional[List[str]] = None, region_deny_list: Optional[List[str]] = None, 54 | scps: Optional[List[List[dict]]] = None, client_args_map: Optional[dict] = None) -> List[Edge]: 55 | """Given a list of nodes and a botocore Session, return a list of edges between those nodes. Only checks 56 | against services passed in the checker_list param. """ 57 | result = [] 58 | logger.info('Initiating edge checks.') 59 | logger.debug('Services being checked for edges: {}'.format(checker_list)) 60 | for check in checker_list: 61 | if check in checker_map: 62 | checker_obj = checker_map[check](session) 63 | result.extend(checker_obj.return_edges(nodes, region_allow_list, region_deny_list, scps, client_args_map)) 64 | return result 65 | -------------------------------------------------------------------------------- /principalmapper/querying/presets/serviceaccess.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) NCC Group and Erik Steringer 2021. This file is part of Principal Mapper. 2 | # 3 | # Principal Mapper is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU Affero General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # Principal Mapper is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with Principal Mapper. If not, see . 15 | 16 | import copy 17 | import logging 18 | import re 19 | from typing import List, Dict 20 | 21 | from principalmapper.common import Graph, Policy, Node 22 | from principalmapper.querying import query_interface 23 | from principalmapper.querying.local_policy_simulation import _listify_string 24 | from principalmapper.util.case_insensitive_dict import CaseInsensitiveDict 25 | 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | 30 | def handle_preset_query(graph: Graph, tokens: List[str], skip_admins: bool = False) -> None: 31 | """Handles a human-readable query that's been chunked into tokens, and prints the results. Prints out the relevant 32 | roles in the account that a service can assume. 33 | 34 | Tokens should be: 35 | 36 | * "preset" 37 | * "endgame" 38 | * : (*|s3|sns|sqs|kms|secretsmanager) 39 | """ 40 | 41 | sam = compose_service_access_map(graph) 42 | for service, roles in sam.items(): 43 | print(service) 44 | print(' ' + str([x.arn.split('/')[-1] for x in roles])) 45 | 46 | 47 | def compose_service_access_map(graph: Graph) -> Dict[str, List[Node]]: 48 | """Given a Graph, create a mapping from services to the IAM Roles in the Graph they can assume.""" 49 | result = {} 50 | 51 | # iterate through all nodes 52 | for n in graph.nodes: # type: Node 53 | # filter to IAM Roles 54 | if ':role/' not in n.arn: 55 | continue 56 | 57 | allow_list = [] 58 | deny_list = [] 59 | if 'Statement' not in n.trust_policy: 60 | continue 61 | 62 | for stmt in n.trust_policy['Statement']: 63 | if 'Principal' not in stmt: 64 | continue # TODO: re-examine inclusion of NotPrincipal in trust docs 65 | if 'Service' in stmt['Principal']: 66 | for element in _listify_string(stmt['Principal']['Service']): 67 | if stmt['Effect'] == 'Allow': 68 | allow_list.append(element) 69 | else: 70 | deny_list.append(element) 71 | 72 | for allowed in allow_list: 73 | if allowed in deny_list: 74 | continue 75 | 76 | if allowed not in result: 77 | result[allowed] = [n] 78 | else: 79 | result[allowed].append(n) 80 | 81 | return result 82 | -------------------------------------------------------------------------------- /principalmapper/util/botocore_tools.py: -------------------------------------------------------------------------------- 1 | """Utility functions for working with botocore""" 2 | 3 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 4 | # 5 | # Principal Mapper is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Principal Mapper is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with Principal Mapper. If not, see . 17 | 18 | import logging 19 | from typing import List, Optional 20 | 21 | import botocore.session 22 | 23 | 24 | logger = logging.getLogger(__name__) 25 | 26 | 27 | def get_session(profile_arg: Optional[str], stsargs: Optional[dict] = None) -> botocore.session.Session: 28 | """Returns a botocore Session object taking into consideration Env-vars, etc. 29 | 30 | Tries to follow order from: https://docs.aws.amazon.com/cli/latest/userguide/cli-chap-configure.html 31 | """ 32 | # command-line args (--profile) 33 | if profile_arg is not None: 34 | result = botocore.session.Session(profile=profile_arg) 35 | else: # pull from environment vars / metadata 36 | result = botocore.session.get_session() 37 | 38 | # handles args for creating the STS client 39 | if stsargs is None: 40 | processed_stsargs = {} 41 | else: 42 | processed_stsargs = stsargs 43 | 44 | stsclient = result.create_client('sts', **processed_stsargs) 45 | stsclient.get_caller_identity() # raises error if it's not workable 46 | return result 47 | 48 | 49 | def get_regions_to_search(session: botocore.session.Session, service_name: str, region_allow_list: Optional[List[str]] = None, region_deny_list: Optional[List[str]] = None) -> List[str]: 50 | """Using a botocore Session object, the name of a service, and either an allow-list or a deny-list (but not both), 51 | return a list of regions to be used during the gathering process. This uses the botocore Session object's 52 | get_available_regions method as the base list. 53 | 54 | If the allow-list is specified, the returned list is the union of the base list and the allow-list. No error is 55 | thrown if a region is specified in the allow-list but not included in the base list. 56 | 57 | If the deny-list is specified, the returned list is the base list minus the elements of the deny-list. No error is 58 | thrown if a region is specified inthe deny-list but not included in the base list. 59 | 60 | A ValueError is thrown if the allow-list AND deny-list are both not None. 61 | """ 62 | 63 | if region_allow_list is not None and region_deny_list is not None: 64 | raise ValueError('This function allows only either the allow-list or the deny-list, but NOT both.') 65 | 66 | base_list = session.get_available_regions(service_name) 67 | 68 | result = [] 69 | 70 | if region_allow_list is not None: 71 | for element in base_list: 72 | if element in region_allow_list: 73 | result.append(element) 74 | elif region_deny_list is not None: 75 | for element in base_list: 76 | if element not in region_deny_list: 77 | result.append(element) 78 | else: 79 | result = base_list 80 | 81 | logger.debug('Final list of regions for {}: {}'.format(service_name, result)) 82 | 83 | return result 84 | -------------------------------------------------------------------------------- /tests/test_admin_identification.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) NCC Group and Erik Steringer 2021. This file is part of Principal Mapper. 2 | # 3 | # Principal Mapper is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU Affero General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # Principal Mapper is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with Principal Mapper. If not, see . 15 | 16 | 17 | import logging 18 | import unittest 19 | 20 | 21 | from principalmapper.common import Node, Policy, Group 22 | from principalmapper.graphing.gathering import update_admin_status 23 | 24 | 25 | class TestAdminIdentification(unittest.TestCase): 26 | def test_admin_verified_by_inline_policies(self): 27 | admin_policy = Policy('arn:aws:iam::000000000000:user/user_1', 'inline_admin', { 28 | 'Version': '2012-10-17', 29 | 'Statement': [{ 30 | 'Effect': 'Allow', 31 | 'Action': '*', 32 | 'Resource': '*' 33 | }] 34 | }) 35 | 36 | not_admin_policy = Policy('arn:aws:iam::000000000000:user/user_2', 'inline_not_admin', { 37 | 'Version': '2012-10-17', 38 | 'Statement': [ 39 | { 40 | 'Effect': 'Allow', 41 | 'Action': '*', 42 | 'Resource': '*' 43 | }, 44 | { 45 | 'Effect': 'Deny', 46 | 'Action': '*', 47 | 'Resource': '*' 48 | } 49 | ] 50 | }) 51 | 52 | new_node_1 = Node('arn:aws:iam::000000000000:user/user_1', 'id1', [admin_policy], [], None, None, 1, False, 53 | False, None, False, None) 54 | new_node_2 = Node('arn:aws:iam::000000000000:user/user_2', 'id2', [not_admin_policy], [], None, None, 1, False, 55 | False, None, False, None) 56 | 57 | update_admin_status([new_node_1, new_node_2]) 58 | 59 | self.assertTrue(new_node_1.is_admin, 'User with admin policy should be marked as an admin') 60 | self.assertFalse(new_node_2.is_admin, 'User with non-admin policy should not be marked as an admin') 61 | 62 | def test_admin_verified_for_group_member(self): 63 | admin_policy = Policy('arn:aws:iam::000000000000:group/admins', 'inline_admin', { 64 | 'Version': '2012-10-17', 65 | 'Statement': [{ 66 | 'Effect': 'Allow', 67 | 'Action': '*', 68 | 'Resource': '*' 69 | }] 70 | }) 71 | 72 | admin_group = Group('arn:aws:iam::000000000000:group/admins', [admin_policy]) 73 | not_admin_group = Group('arn:aws:iam::000000000000:group/losers', []) 74 | 75 | new_node_1 = Node('arn:aws:iam::000000000000:user/node_1', 'id1', [], [admin_group], None, None, 1, False, 76 | False, None, False, None) 77 | new_node_2 = Node('arn:aws:iam::000000000000:user/node_2', 'id2', [], [not_admin_group], None, None, 1, False, 78 | False, None, False, None) 79 | 80 | update_admin_status([new_node_1, new_node_2]) 81 | 82 | self.assertTrue(new_node_1.is_admin, 'Member of admin group should be marked as an admin') 83 | self.assertFalse(new_node_2.is_admin, 'Member of non-admin group should not be marked as an admin') 84 | -------------------------------------------------------------------------------- /principalmapper/querying/presets/connected.py: -------------------------------------------------------------------------------- 1 | """Query preset for testing if a principal is connected to another, or listing what a principal is connected to.""" 2 | 3 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 4 | # 5 | # Principal Mapper is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Principal Mapper is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with Principal Mapper. If not, see . 17 | 18 | import io 19 | import os 20 | from typing import List 21 | 22 | from principalmapper.common import Edge, Node, Graph 23 | from principalmapper.querying.query_utils import get_search_list 24 | 25 | 26 | def handle_preset_query(graph: Graph, tokens: List[str], skip_admins: bool = False) -> None: 27 | """Handles a human-readable query that's been chunked into tokens, and writes the result to output.""" 28 | source_target = tokens[2] 29 | dest_target = tokens[3] 30 | 31 | source_nodes = [] 32 | dest_nodes = [] 33 | if source_target == '*': 34 | source_nodes.extend(graph.nodes) 35 | else: 36 | source_nodes.append(graph.get_node_by_searchable_name(source_target)) 37 | 38 | if dest_target == '*': 39 | dest_nodes.extend(graph.nodes) 40 | else: 41 | dest_nodes.append(graph.get_node_by_searchable_name(dest_target)) 42 | 43 | print_connected_results(graph, source_nodes, dest_nodes, skip_admins) 44 | 45 | 46 | def print_connected_results(graph: Graph, source_nodes: List[Node], dest_nodes: List[Node], skip_admins: bool = False) -> None: 47 | """Handles a `connected` query and writes the results to output""" 48 | for snode in source_nodes: 49 | if skip_admins and snode.is_admin: 50 | continue 51 | 52 | for dnode in dest_nodes: 53 | connection_result, path = is_connected(graph, snode, dnode) 54 | if connection_result: 55 | # print the data 56 | print('{} is able to access {}:'.format(snode.searchable_name(), dnode.searchable_name())) 57 | for edge in path: 58 | print(' {}'.format(edge.describe_edge())) 59 | print() 60 | 61 | 62 | def write_connected_results(graph: Graph, source_nodes: List[Node], dest_nodes: List[Node], skip_admins: bool = False, 63 | output: io.StringIO = os.devnull) -> None: 64 | """Handles a `connected` query and writes the results to output""" 65 | for snode in source_nodes: 66 | if skip_admins and snode.is_admin: 67 | continue 68 | 69 | for dnode in dest_nodes: 70 | connection_result, path = is_connected(graph, snode, dnode) 71 | if connection_result: 72 | # print the data 73 | output.write('{} is able to access {}:\n'.format(snode.searchable_name(), dnode.searchable_name())) 74 | for edge in path: 75 | output.write(' {}\n'.format(edge.describe_edge())) 76 | 77 | 78 | def is_connected(graph: Graph, source_node: Node, dest_node: Node) -> (bool, List[Edge]): 79 | """Method for determining if a source node can reach a destination node through edges. The return value is a 80 | bool, List[Edge] tuple indicating if there's a connection and the path the source node would need to take. 81 | """ 82 | edge_lists = get_search_list(graph, source_node) 83 | for edge_list in edge_lists: 84 | final_node = edge_list[-1].destination 85 | if final_node == dest_node: 86 | return True, edge_list 87 | 88 | return False, None 89 | -------------------------------------------------------------------------------- /principalmapper/util/case_insensitive_dict.py: -------------------------------------------------------------------------------- 1 | """Python code for a case-insensitive dictionary.""" 2 | 3 | # Copyright (c) NCC Group and Erik Steringer 2021. This file is part of Principal Mapper. 4 | # 5 | # Principal Mapper is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Principal Mapper is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with Principal Mapper. If not, see . 17 | # 18 | # This file incorporates work covered by the following copyright and permission notice: 19 | # 20 | # Copyright 2019 Kenneth Reitz 21 | # 22 | # Licensed under the Apache License, Version 2.0 (the "License"); 23 | # you may not use this file except in compliance with the License. 24 | # You may obtain a copy of the License at 25 | # 26 | # http://www.apache.org/licenses/LICENSE-2.0 27 | # 28 | # Unless required by applicable law or agreed to in writing, software 29 | # distributed under the License is distributed on an "AS IS" BASIS, 30 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 31 | # See the License for the specific language governing permissions and 32 | # limitations under the License. 33 | 34 | from collections.abc import Mapping, MutableMapping 35 | from collections import OrderedDict 36 | 37 | class CaseInsensitiveDict(MutableMapping): 38 | """A case-insensitive ``dict``-like object. 39 | Implements all methods and operations of 40 | ``MutableMapping`` as well as dict's ``copy``. Also 41 | provides ``lower_items``. 42 | 43 | All keys are expected to be strings. The structure remembers the 44 | case of the last key to be set, and ``iter(instance)``, 45 | ``keys()``, ``items()``, ``iterkeys()``, and ``iteritems()`` 46 | will contain case-sensitive keys. However, querying and contains 47 | testing is case insensitive:: 48 | cid = CaseInsensitiveDict() 49 | cid['aws:SourceIp'] = '128.223.0.1' 50 | cid['aws:sourceip'] == '128.223.0.1' # True 51 | 52 | For example, ``context['ec2:InstanceType']`` will return the 53 | value of ec2:InstanceType condition context, regardless 54 | of how the name was originally stored. 55 | 56 | If the constructor, ``.update``, or equality comparison 57 | operations are given keys that have equal ``.lower()``s, the 58 | behavior is undefined. 59 | """ 60 | 61 | def __init__(self, data=None, **kwargs): 62 | self._store = OrderedDict() 63 | if data is None: 64 | data = {} 65 | self.update(data, **kwargs) 66 | 67 | def __setitem__(self, key, value): 68 | # Use the lowercased key for lookups, but store the actual 69 | # key alongside the value. 70 | self._store[key.lower()] = (key, value) 71 | 72 | def __getitem__(self, key): 73 | return self._store[key.lower()][1] 74 | 75 | def __delitem__(self, key): 76 | del self._store[key.lower()] 77 | 78 | def __iter__(self): 79 | return (casedkey for casedkey, mappedvalue in self._store.values()) 80 | 81 | def __len__(self): 82 | return len(self._store) 83 | 84 | def lower_items(self): 85 | """Like iteritems(), but with all lowercase keys.""" 86 | return ( 87 | (lowerkey, keyval[1]) 88 | for (lowerkey, keyval) 89 | in self._store.items() 90 | ) 91 | 92 | def __eq__(self, other): 93 | if isinstance(other, Mapping): 94 | other = CaseInsensitiveDict(other) 95 | else: 96 | return NotImplemented 97 | # Compare insensitively 98 | return dict(self.lower_items()) == dict(other.lower_items()) 99 | 100 | # Copy is required 101 | def copy(self): 102 | return CaseInsensitiveDict(self._store.values()) 103 | 104 | def __repr__(self): 105 | return str(dict(self.items())) 106 | -------------------------------------------------------------------------------- /principalmapper/querying/presets/privesc.py: -------------------------------------------------------------------------------- 1 | """Query preset for testing if a principal can escalate privileges. This is intentionally broken up into multiple 2 | methods to make it usable programmatically. Call can_privesc with a Graph and Node to get results that don't require 3 | parsing text output.""" 4 | 5 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 6 | # 7 | # Principal Mapper is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Principal Mapper is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with Principal Mapper. If not, see . 19 | 20 | import io 21 | import os 22 | from typing import List 23 | 24 | from principalmapper.common import Edge, Node, Graph 25 | from principalmapper.querying.query_utils import get_search_list 26 | 27 | 28 | def handle_preset_query(graph: Graph, tokens: List[str], skip_admins: bool = False) -> None: 29 | """Handles a human-readable query that's been chunked into tokens, and prints the result.""" 30 | 31 | # Get the nodes we're determining can privesc or not 32 | target = tokens[2] 33 | nodes = [] 34 | if target == '*': 35 | nodes.extend(graph.nodes) 36 | else: 37 | nodes.append(graph.get_node_by_searchable_name(target)) 38 | print_privesc_results(graph, nodes, skip_admins) 39 | 40 | 41 | def print_privesc_results(graph: Graph, nodes: List[Node], skip_admins: bool = False) -> None: 42 | """Handles a privesc query and writes the result to output.""" 43 | for node in nodes: 44 | if skip_admins and node.is_admin: 45 | continue # skip admins 46 | 47 | if node.is_admin: 48 | print('{} is an administrative principal'.format(node.searchable_name())) 49 | continue 50 | 51 | privesc, edge_list = can_privesc(graph, node) 52 | if privesc: 53 | end_of_list = edge_list[-1].destination 54 | # the node can access this admin node through the current edge list, print this info out 55 | print('{} can escalate privileges by accessing the administrative principal {}:'.format( 56 | node.searchable_name(), end_of_list.searchable_name())) 57 | for edge in edge_list: 58 | print(' {}'.format(edge.describe_edge())) 59 | print() 60 | 61 | 62 | def write_privesc_results(graph: Graph, nodes: List[Node], skip_admins: bool, output: io.StringIO) -> None: 63 | """Handles a privesc query and writes the result to output. 64 | 65 | **Change, v1.1.x:** The `output` param is no longer optional. The `skip_admins` param is no longer optional.""" 66 | for node in nodes: 67 | if skip_admins and node.is_admin: 68 | continue # skip admins 69 | 70 | if node.is_admin: 71 | output.write('{} is an administrative principal\n'.format(node.searchable_name())) 72 | continue 73 | 74 | privesc, edge_list = can_privesc(graph, node) 75 | if privesc: 76 | end_of_list = edge_list[-1].destination 77 | # the node can access this admin node through the current edge list, print this info out 78 | output.write('{} can escalate privileges by accessing the administrative principal {}:\n'.format( 79 | node.searchable_name(), end_of_list.searchable_name())) 80 | for edge in edge_list: 81 | output.write(' {}\n'.format(edge.describe_edge())) 82 | 83 | 84 | def can_privesc(graph: Graph, node: Node) -> (bool, List[Edge]): 85 | """Method for determining if a given Node in a Graph can escalate privileges. 86 | 87 | Returns a bool, List[Edge] tuple. The bool indicates if there is a privesc risk, and the List[Edge] component 88 | describes the path of edges the node would have to take to gain access to the admin node. 89 | """ 90 | edge_lists = get_search_list(graph, node) 91 | searched_nodes = [] 92 | for edge_list in edge_lists: 93 | # check if the node at the end of the list has been looked at yet, skip if so 94 | end_of_list = edge_list[-1].destination 95 | if end_of_list in searched_nodes: 96 | continue 97 | 98 | # add end of list to the searched nodes and do the privesc check 99 | searched_nodes.append(end_of_list) 100 | if end_of_list.is_admin: 101 | return True, edge_list 102 | return False, None 103 | -------------------------------------------------------------------------------- /principalmapper/graphing/cross_account_edges.py: -------------------------------------------------------------------------------- 1 | """Code to derive a collection of edges between different Graph objects.""" 2 | 3 | # Copyright (c) NCC Group and Erik Steringer 2021. This file is part of Principal Mapper. 4 | # 5 | # Principal Mapper is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Principal Mapper is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with Principal Mapper. If not, see . 17 | 18 | import datetime as dt 19 | import logging 20 | from typing import List, Optional 21 | 22 | from principalmapper.common import Edge, Graph 23 | from principalmapper.querying.query_interface import local_check_authorization_full 24 | from principalmapper.util import arns 25 | 26 | logger = logging.getLogger(__name__) 27 | 28 | 29 | def get_edges_between_graphs(graph_a: Graph, graph_b: Graph, scps_a: Optional[List[List[dict]]] = None, scps_b: Optional[List[List[dict]]] = None) -> List[Edge]: 30 | """Given two Graph objects, return a list of Edge objects that represent the connections between 31 | the two Graphs (both to and from). Currently only does sts:AssumeRole checks.""" 32 | 33 | result = [] # type: List[Edge] 34 | 35 | def _check_assume_role(ga, na, gb, nb, scps) -> bool: 36 | logger.debug('Checking if {} can access {}'.format(na.arn, nb.arn)) 37 | 38 | # load up conditions: inspired by _infer_condition_keys 39 | conditions = {} 40 | conditions['aws:CurrentTime'] = dt.datetime.now(dt.timezone.utc).isoformat() 41 | conditions['aws:EpochTime'] = str(round(dt.datetime.now(dt.timezone.utc).timestamp())) 42 | conditions['aws:userid'] = na.id_value 43 | 44 | if ':user/' in na.arn: 45 | conditions['aws:username'] = na.searchable_name().split('/')[1] 46 | 47 | conditions['aws:SecureTransport'] = 'true' 48 | conditions['aws:PrincipalAccount'] = ga.metadata['account_id'] 49 | conditions['aws:PrincipalArn'] = na.arn 50 | if 'org-id' in ga.metadata: 51 | conditions['aws:PrincipalOrgID'] = ga.metadata['org-id'] 52 | if 'org-path' in ga.metadata: 53 | conditions['aws:PrincipalOrgPaths'] = ga.metadata['org-path'] 54 | 55 | for tag_key, tag_value in na.tags.items(): 56 | conditions['aws:PrincipalTag/{}'.format(tag_key)] = tag_value 57 | 58 | for statement in nb.trust_policy['Statement']: 59 | external_id = statement.get('Condition', {}).get('StringEquals', {}).get('sts:ExternalId') 60 | if external_id: 61 | conditions['sts:ExternalId'] = external_id 62 | continue 63 | 64 | # check without MFA 65 | auth_result = local_check_authorization_full( 66 | na, 67 | 'sts:AssumeRole', 68 | nb.arn, 69 | conditions, 70 | nb.trust_policy, 71 | arns.get_account_id(nb.arn), 72 | scps 73 | ) 74 | 75 | if auth_result: 76 | return True 77 | 78 | # check with MFA 79 | conditions.update({ 80 | 'aws:MultiFactorAuthAge': '1', 81 | 'aws:MultiFactorAuthPresent': 'true' 82 | }) 83 | auth_result = local_check_authorization_full( 84 | na, 85 | 'sts:AssumeRole', 86 | nb.arn, 87 | conditions, 88 | nb.trust_policy, 89 | arns.get_account_id(nb.arn), 90 | scps 91 | ) 92 | 93 | return auth_result 94 | 95 | def _describe_edge(na, nb) -> str: 96 | """Quick method for generating strings describing edges.""" 97 | return '{} -> {}'.format( 98 | '{}/{}'.format(arns.get_account_id(na.arn), na.searchable_name()), 99 | '{}/{}'.format(arns.get_account_id(nb.arn), nb.searchable_name()) 100 | ) 101 | 102 | for node_a in graph_a.nodes: 103 | for node_b in graph_b.nodes: 104 | # check a -> b 105 | if node_b.searchable_name().startswith('role/'): 106 | if _check_assume_role(graph_a, node_a, graph_b, node_b, scps_a): 107 | logger.info('Found edge: {}'.format(_describe_edge(node_a, node_b))) 108 | result.append(Edge(node_a, node_b, 'can call sts:AssumeRole to access', 'STS')) 109 | 110 | # check b -> a 111 | # if node_a.searchable_name().startswith('role/'): 112 | # if _check_assume_role(graph_b, node_b, graph_a, node_a, scps_b): 113 | # logger.info('Found edge: {}'.format(_describe_edge(node_b, node_a))) 114 | # result.append(Edge(node_b, node_a, 'can call sts:AssumeRole to access', 'STS')) 115 | 116 | return result 117 | -------------------------------------------------------------------------------- /principalmapper/graphing/graph_actions.py: -------------------------------------------------------------------------------- 1 | """Code for executing commands given by the Principal Mapper command-line""" 2 | 3 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 4 | # 5 | # Principal Mapper is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Principal Mapper is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with Principal Mapper. If not, see . 17 | 18 | import logging 19 | import os 20 | import os.path 21 | import sys 22 | 23 | import botocore.session 24 | from principalmapper.common import Graph 25 | from principalmapper.graphing import gathering 26 | from principalmapper.util.storage import get_default_graph_path 27 | from typing import List, Optional 28 | 29 | 30 | logger = logging.getLogger(__name__) 31 | 32 | 33 | def create_new_graph(session: botocore.session.Session, service_list: List[str], 34 | region_allow_list: Optional[List[str]] = None, region_deny_list: Optional[List[str]] = None, 35 | scps: Optional[List[List[dict]]] = None, client_args_map: Optional[dict] = None) -> Graph: 36 | """Wraps around principalmapper.graphing.gathering.create_graph(...) This fulfills `pmapper graph create`. 37 | """ 38 | 39 | return gathering.create_graph(session, service_list, region_allow_list, region_deny_list, scps, client_args_map) 40 | 41 | def create_new_graph_without_edges(session: botocore.session.Session, service_list: List[str], 42 | region_allow_list: Optional[List[str]] = None, region_deny_list: Optional[List[str]] = None, 43 | scps: Optional[List[List[dict]]] = None, client_args_map: Optional[dict] = None) -> Graph: 44 | """Wraps around principalmapper.graphing.gathering.create_graph(...) This fulfills `pmapper graph create`. 45 | """ 46 | 47 | return gathering.create_graph_without_edges(session, service_list, region_allow_list, region_deny_list, scps, client_args_map) 48 | 49 | 50 | def print_graph_data(graph: Graph) -> None: 51 | """Given a Graph object, prints a small amount of information about the Graph. This fulfills 52 | `pmapper graph display`, and also gets ran after `pmapper graph --create`. 53 | """ 54 | print('Graph Data for Account: {}'.format(graph.metadata['account_id'])) 55 | if 'org-id' in graph.metadata: 56 | print(' Organization: {}'.format(graph.metadata['org-id'])) 57 | print(' OU Path: {}'.format(graph.metadata['org-path'])) 58 | admin_count = 0 59 | for node in graph.nodes: 60 | if node.is_admin: 61 | admin_count += 1 62 | print(' # of Nodes: {} ({} admins)'.format(len(graph.nodes), admin_count)) 63 | print(' # of Edges: {}'.format(len(graph.edges))) 64 | print(' # of Groups: {}'.format(len(graph.groups))) 65 | print(' # of (tracked) Policies: {}'.format(len(graph.policies))) 66 | 67 | 68 | def get_graph_from_disk(location: str) -> Graph: 69 | """Returns a Graph object constructed from data stored on-disk at any location. This basically wraps around the 70 | static method in principalmapper.common.graph named Graph.create_graph_from_local_disk(...). 71 | """ 72 | 73 | return Graph.create_graph_from_local_disk(location) 74 | 75 | 76 | def get_existing_graph(session: Optional[botocore.session.Session], account: Optional[str]) -> Graph: 77 | """Returns a Graph object stored on-disk in a standard location (per-OS, using the get_storage_root utility function 78 | in principalmapper.util.storage). Uses the session/account parameter to choose the directory from under the 79 | standard location. 80 | """ 81 | 82 | try: 83 | if account is not None: 84 | logger.debug('Loading graph based on given account id: {}'.format(account)) 85 | graph = get_graph_from_disk(get_default_graph_path(account)) 86 | elif session is not None: 87 | stsclient = session.create_client('sts') 88 | account = stsclient.get_caller_identity().get('Account') 89 | logger.debug('Loading graph based on sts:GetCallerIdentity result: {}'.format(account)) 90 | graph = get_graph_from_disk(os.path.join(get_default_graph_path(account))) 91 | else: 92 | raise ValueError('One of the parameters `account` or `session` must not be None') 93 | return graph 94 | except Exception as ex: 95 | logger.warning('Unable to load a Graph object for account {}, possibly because it is not mapped yet. ' 96 | 'Please map all accounts and then update the Organization Tree ' 97 | '(`pmapper orgs update --org $ORG_ID`).'.format(account)) 98 | logger.debug(str(ex)) 99 | return None 100 | 101 | -------------------------------------------------------------------------------- /principalmapper/querying/query_orgs.py: -------------------------------------------------------------------------------- 1 | """Code for helping run queries when AWS Organizations are involved""" 2 | 3 | # Copyright (c) NCC Group and Erik Steringer 2021. This file is part of Principal Mapper. 4 | # 5 | # Principal Mapper is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Principal Mapper is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with Principal Mapper. If not, see . 17 | 18 | import logging 19 | from typing import List, Optional 20 | 21 | from principalmapper.common import Graph, OrganizationTree, Policy, OrganizationNode 22 | 23 | 24 | logger = logging.getLogger(__name__) 25 | 26 | 27 | def _grab_policies_and_traverse(org_nodes: List[OrganizationNode], parts, index, account_id, result): 28 | for org_node in org_nodes: 29 | if org_node.ou_id != parts[index]: 30 | continue 31 | else: 32 | # ASSUMPTION: all OUs/Accounts in an org HAVE to have at least one policy when SCPs are enabled, so this 33 | # catches the alternative and does an early return of None. None is interpreted by querying mechanisms 34 | # as meaning SCPs are NOT considered during authorization 35 | if len(org_node.scps) == 0: 36 | return None 37 | result.append([x.policy_doc for x in org_node.scps]) 38 | if parts[index + 1] == '': 39 | for account in org_node.accounts: 40 | if account.account_id == account_id: 41 | result.append([x.policy_doc for x in account.scps]) 42 | else: 43 | _grab_policies_and_traverse(org_node.child_nodes, parts, index + 1, account_id, result) 44 | 45 | 46 | def _find_path_or_traverse(search_path: List[str], org_nodes: List[OrganizationNode], target_account: str): 47 | logger.debug(search_path) 48 | for org_node in org_nodes: 49 | search_path.append(org_node.ou_id) 50 | if target_account in [x.account_id for x in org_node.accounts]: 51 | return '/'.join(search_path) + '/' 52 | else: 53 | traverse_result = _find_path_or_traverse(search_path, org_node.child_nodes, target_account) 54 | if traverse_result is not None: 55 | return traverse_result 56 | search_path.pop() 57 | return None 58 | 59 | 60 | def produce_scp_list_by_account_id(account_id: str, org: OrganizationTree) -> Optional[List[List[dict]]]: 61 | """Given a Graph object and its encompassing OrganizationTree data, produce the hierarchy of SCPs that can be 62 | fed to `local_check_authorization_full`. 63 | 64 | If the graph belongs to the account that is the management account for the organization, then we return None 65 | because SCPs cannot restrict the management account's authorization behavior. When we pass None to 66 | `local_check_authorization_full`, that means that it won't include SCPs during simulation which is what we 67 | want in that case. 68 | 69 | This version differs from `produce_scp_list` in that it does not require the full Graph object. This is 70 | useful during the graph-creation process so that we can handle SCPs.""" 71 | 72 | result = [] 73 | 74 | search_stack = [org.org_id] 75 | search_path = _find_path_or_traverse(search_stack, org.root_ous, account_id) 76 | logger.debug('Account organization path: {}'.format(search_path)) 77 | search_path_parts = search_path.split('/') 78 | _grab_policies_and_traverse(org.root_ous, search_path_parts, 1, account_id, result) 79 | 80 | return result 81 | 82 | 83 | def produce_scp_list(graph: Graph, org: OrganizationTree) -> Optional[List[List[dict]]]: 84 | """Given a Graph object and its encompassing OrganizationTree data, produce the hierarchy of SCPs that can be 85 | fed to `local_check_authorization_full`. 86 | 87 | If the graph belongs to the account that is the management account for the organization, then we return None 88 | because SCPs cannot restrict the management account's authorization behavior. When we pass None to 89 | `local_check_authorization_full`, that means that it won't include SCPs during simulation which is what we 90 | want in that case.""" 91 | 92 | if 'org-id' not in graph.metadata or 'org-path' not in graph.metadata: 93 | raise ValueError('Given graph for account {} does not have AWS Organizations data (try running ' 94 | '`pmapper orgs create/update`).') 95 | 96 | if graph.metadata['account_id'] == org.management_account_id: 97 | return None 98 | 99 | result = [] 100 | 101 | # org-path is in the form '//[///]' so we split and start from [1] 102 | org_path_parts = graph.metadata['org-path'].split('/') 103 | 104 | _grab_policies_and_traverse(org.root_ous, org_path_parts, 1, graph.metadata['account_id'], result) 105 | 106 | return result 107 | -------------------------------------------------------------------------------- /principalmapper/querying/presets/endgame.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) NCC Group and Erik Steringer 2021. This file is part of Principal Mapper. 2 | # 3 | # Principal Mapper is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU Affero General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # Principal Mapper is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with Principal Mapper. If not, see . 15 | 16 | import copy 17 | import logging 18 | import re 19 | from typing import List, Dict 20 | 21 | from principalmapper.common import Graph, Policy, Node 22 | from principalmapper.querying import query_interface 23 | from principalmapper.util.case_insensitive_dict import CaseInsensitiveDict 24 | 25 | _service_resource_exposure_map = { 26 | 's3': { 27 | 'pattern': re.compile(r"^arn:aws:s3:::[^/]+$"), 28 | 'actions': ['s3:PutBucketPolicy'] 29 | }, 30 | 'sns': { 31 | 'pattern': re.compile(r"^arn:aws:sns:[a-z0-9-]+:[0-9]+:.*"), 32 | 'actions': ['sns:AddPermission', 'sns:SetTopicAttributes'] 33 | }, 34 | 'sqs': { 35 | 'pattern': re.compile(r"^arn:aws:sqs:[a-z0-9-]+:[0-9]+:.*"), 36 | 'actions': ['sqs:AddPermission', 'sqs:SetQueueAttributes'] 37 | }, 38 | 'kms': { 39 | 'pattern': re.compile(r"^arn:aws:kms:[a-z0-9-]+:[0-9]+:key/.*"), 40 | 'actions': ['kms:PutKeyPolicy'] 41 | }, 42 | 'secretsmanager': { 43 | 'pattern': re.compile(r"^arn:aws:secretsmanager:[a-z0-9-]+:[0-9]+:.*"), 44 | 'actions': ['secretsmanager:PutResourcePolicy'] 45 | } 46 | } 47 | 48 | 49 | def handle_preset_query(graph: Graph, tokens: List[str], skip_admins: bool = False) -> None: 50 | """Handles a human-readable query that's been chunked into tokens, and prints the results. Prints out the relevant 51 | resources vs nodes and marks the relevant cells where a principal can alter the resource policy and broaden it 52 | to world-read. 53 | 54 | Tokens should be: 55 | 56 | * "preset" 57 | * "endgame" 58 | * : (*|s3|sns|sqs|kms|secretsmanager) 59 | """ 60 | endgame_map = compose_endgame_map(graph, tokens[2], skip_admins) 61 | for policy, nodes in endgame_map.items(): 62 | print(policy.arn) 63 | print(' {}'.format([x.searchable_name() for x in nodes])) 64 | 65 | 66 | def compose_endgame_map(graph: Graph, service_to_include: str = '*', skip_admins: bool = False) -> Dict[Policy, List[Node]]: 67 | """Given a Graph and a service to look at, compose and return a map that includes the different 68 | users/roles versus the resources they're able to open up for world-access.""" 69 | 70 | result = {} 71 | 72 | for policy in graph.policies: 73 | for service, definition in _service_resource_exposure_map.items(): 74 | if service_to_include == '*' or ':{}:'.format(service_to_include) in policy.arn: 75 | if definition['pattern'].match(policy.arn) is not None: 76 | result[policy] = [] 77 | 78 | for node in graph.nodes: 79 | node_confirmed = False 80 | 81 | if 'conditions' not in node.cache: 82 | node.cache['conditions'] = query_interface._infer_condition_keys(node, CaseInsensitiveDict()) 83 | 84 | if (not skip_admins) or (not node.is_admin): 85 | for action in definition['actions']: 86 | if node_confirmed: 87 | continue 88 | 89 | query_result = query_interface.local_check_authorization_full( 90 | node, action, policy.arn, node.cache['conditions'], policy.policy_doc, graph.metadata['account_id'], 91 | None, None 92 | ) 93 | 94 | if query_result: 95 | node_confirmed = True 96 | 97 | elif node.has_mfa: 98 | conditions_copy = copy.deepcopy(node.cache['conditions']) 99 | conditions_copy.update({ 100 | 'aws:MultiFactorAuthAge': '1', 101 | 'aws:MultiFactorAuthPresent': 'true' 102 | }) 103 | query_result = query_interface.local_check_authorization_full( 104 | node, action, policy.arn, conditions_copy, policy.policy_doc, 105 | graph.metadata['account_id'], 106 | None, None 107 | ) 108 | if query_result: 109 | node_confirmed = True 110 | 111 | if node_confirmed: 112 | result[policy].append(node) 113 | 114 | return result 115 | -------------------------------------------------------------------------------- /principalmapper/querying/query_cli.py: -------------------------------------------------------------------------------- 1 | """Code to implement the CLI interface to the query component of Principal Mapper""" 2 | 3 | # Copyright (c) NCC Group and Erik Steringer 2020. This file is part of Principal Mapper. 4 | # 5 | # Principal Mapper is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Principal Mapper is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with Principal Mapper. If not, see . 17 | 18 | from argparse import ArgumentParser, Namespace 19 | import os 20 | import os.path 21 | import json 22 | import logging 23 | 24 | from principalmapper.common import OrganizationTree, Policy 25 | from principalmapper.graphing import graph_actions 26 | from principalmapper.querying import query_utils, query_actions, query_orgs 27 | from principalmapper.util import botocore_tools, arns 28 | from principalmapper.util.storage import get_storage_root 29 | 30 | logger = logging.getLogger(__name__) 31 | 32 | 33 | def provide_arguments(parser: ArgumentParser): 34 | """Given a parser object (which should be a subparser), add arguments to provide a CLI interface to the 35 | query component of Principal Mapper. 36 | """ 37 | parser.add_argument( 38 | '-s', 39 | '--skip-admin', 40 | action='store_true', 41 | help='Ignores "admin" level principals when querying about multiple principals in an account' 42 | ) 43 | parser.add_argument( 44 | '-u', 45 | '--include-unauthorized', 46 | action='store_true', 47 | help='Includes output to say if a given principal is not able to call an action.' 48 | ) 49 | query_rpolicy_args = parser.add_mutually_exclusive_group() 50 | query_rpolicy_args.add_argument( 51 | '--with-resource-policy', 52 | action='store_true', 53 | help='Retrieves and includes the resource policy for the resource in the query. Handles S3, IAM, SNS, SQS, and KMS.' 54 | ) 55 | query_rpolicy_args.add_argument( 56 | '--resource-policy-text', 57 | help='The full text of a resource policy to consider during authorization evaluation.' 58 | ) 59 | parser.add_argument( 60 | '--resource-owner', 61 | help='The account ID of the owner of the resource. Required for S3 objects (which do not have it in the ARN).' 62 | ) 63 | parser.add_argument( 64 | '--session-policy', 65 | help='The full text of a session policy to consider during authorization evaluation.' 66 | ) 67 | parser.add_argument( 68 | '--scps', 69 | action='store_true', 70 | help='When specified, the SCPs that apply to the account are taken into consideration.' 71 | ) 72 | parser.add_argument( 73 | 'query', 74 | help='The query to execute.' 75 | ) 76 | 77 | 78 | def process_arguments(parsed_args: Namespace): 79 | """Given a namespace object generated from parsing args, perform the appropriate tasks. Returns an int 80 | matching expectations set by /usr/include/sysexits.h for command-line utilities.""" 81 | 82 | if parsed_args.account is None: 83 | session = botocore_tools.get_session(parsed_args.profile) 84 | else: 85 | session = None 86 | 87 | graph = graph_actions.get_existing_graph(session, parsed_args.account) 88 | logger.debug('Querying against graph {}'.format(graph.metadata['account_id'])) 89 | 90 | if parsed_args.with_resource_policy: 91 | resource_policy = query_utils.pull_cached_resource_policy_by_arn( 92 | graph, 93 | arn=None, 94 | query=parsed_args.query 95 | ) 96 | elif parsed_args.resource_policy_text: 97 | resource_policy = json.loads(parsed_args.resource_policy_text) 98 | else: 99 | resource_policy = None 100 | 101 | resource_owner = parsed_args.resource_owner 102 | if resource_policy is not None: 103 | if resource_owner is None: 104 | if arns.get_service(resource_policy.arn) == 's3': 105 | raise ValueError('Must supply resource owner (--resource-owner) when including S3 bucket policies ' 106 | 'in a query') 107 | else: 108 | resource_owner = arns.get_account_id(resource_policy.arn) 109 | if isinstance(resource_policy, Policy): 110 | resource_policy = resource_policy.policy_doc 111 | 112 | if parsed_args.scps: 113 | if 'org-id' in graph.metadata and 'org-path' in graph.metadata: 114 | org_tree_path = os.path.join(get_storage_root(), graph.metadata['org-id']) 115 | org_tree = OrganizationTree.create_from_dir(org_tree_path) 116 | scps = query_orgs.produce_scp_list(graph, org_tree) 117 | else: 118 | raise ValueError('Graph for account {} does not have an associated OrganizationTree mapped (need to run ' 119 | '`pmapper orgs create/update` to get that.') 120 | else: 121 | scps = None 122 | 123 | query_actions.query_response( 124 | graph, parsed_args.query, parsed_args.skip_admin, resource_policy, resource_owner, 125 | parsed_args.include_unauthorized, parsed_args.session_policy, scps 126 | ) 127 | 128 | return 0 129 | -------------------------------------------------------------------------------- /tests/test_local_policy_sim.py: -------------------------------------------------------------------------------- 1 | """Test functions for local policy simulation""" 2 | 3 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 4 | # 5 | # Principal Mapper is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Principal Mapper is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with Principal Mapper. If not, see . 17 | 18 | import logging 19 | import unittest 20 | 21 | from principalmapper.querying.local_policy_simulation import _matches_after_expansion, _statement_matches_action, _statement_matches_resource, _get_condition_match 22 | from principalmapper.util.case_insensitive_dict import CaseInsensitiveDict 23 | 24 | 25 | class TestLocalPolicyStatementMatching(unittest.TestCase): 26 | def test_action_matching(self): 27 | statement_1 = { 28 | 'Effect': 'Allow', 29 | 'Action': ['ec2:RunInstances', 's3:*', 'iam:Get*'], 30 | 'Resource': '*' 31 | } 32 | self.assertTrue(_statement_matches_action(statement_1, 'ec2:RunInstances')) 33 | self.assertTrue(_statement_matches_action(statement_1, 's3:GetObject')) 34 | self.assertTrue(_statement_matches_action(statement_1, 'iam:GetRole')) 35 | self.assertFalse(_statement_matches_action(statement_1, 'ec2:DescribeInstances')) 36 | self.assertFalse(_statement_matches_action(statement_1, 'iam:PutRolePolicy')) 37 | 38 | statement_2 = { 39 | 'Effect': 'Allow', 40 | 'Action': '*', 41 | 'Resource': '*' 42 | } 43 | self.assertTrue(_statement_matches_action(statement_2, 'iam:GetRole')) 44 | 45 | statement_3 = { 46 | 'Effect': 'Allow', 47 | 'NotAction': '*', 48 | 'Resource': '*' 49 | } 50 | self.assertFalse(_statement_matches_action(statement_3, 'iam:GetRole')) 51 | 52 | statement_4 = { 53 | 'Effect': 'Allow', 54 | 'NotAction': ['iam:*', 's3:Put*'], 55 | 'Resource': '*' 56 | } 57 | self.assertFalse(_statement_matches_action(statement_4, 'iam:GetRole')) 58 | self.assertFalse(_statement_matches_action(statement_4, 's3:PutObject')) 59 | self.assertTrue(_statement_matches_action(statement_4, 'ec2:RunInstances')) 60 | 61 | def test_resource_matching(self): 62 | statement_1 = { 63 | 'Effect': 'Allow', 64 | 'Action': '*', 65 | 'Resource': ['arn:aws:s3:::bucket/*', 'arn:aws:s3:::*/object', 'arn:aws:s3:::${aws:SourceAccount}/win'] 66 | } 67 | self.assertTrue(_statement_matches_resource(statement_1, 'arn:aws:s3:::bucket/anything')) 68 | self.assertTrue(_statement_matches_resource(statement_1, 'arn:aws:s3:::anything/object')) 69 | self.assertTrue(_statement_matches_resource(statement_1, 'arn:aws:s3:::000000000000/win', {'aws:SourceAccount': '000000000000'})) 70 | 71 | statement_2 = { 72 | 'Effect': 'Allow', 73 | 'Action': '*', 74 | 'NotResource': ['arn:aws:s3:::bucket/*', 'arn:aws:s3:::*/object', 'arn:aws:s3:::${aws:SourceAccount}/win'] 75 | } 76 | self.assertFalse(_statement_matches_resource(statement_2, 'arn:aws:s3:::bucket/anything')) 77 | self.assertFalse(_statement_matches_resource(statement_2, 'arn:aws:s3:::anything/object')) 78 | self.assertFalse(_statement_matches_resource(statement_2, 'arn:aws:s3:::000000000000/win', 79 | {'aws:SourceAccount': '000000000000'})) 80 | 81 | def test_condition_matching(self): 82 | condition_1 = { 83 | 'IpAddress': { 84 | 'aws:SourceIp': '128.223.0.0/16' 85 | } 86 | } 87 | self.assertTrue( 88 | _get_condition_match( 89 | condition_1, 90 | CaseInsensitiveDict({ 91 | 'aws:SourceIp': '128.223.0.1' 92 | }) 93 | ) 94 | ) 95 | self.assertTrue( 96 | _get_condition_match( 97 | condition_1, 98 | CaseInsensitiveDict({ 99 | 'aws:sourceip': '128.223.0.1' 100 | }) 101 | ), 102 | 'Condition keys are supposed to be case-insensitive' 103 | ) 104 | 105 | 106 | class TestLocalPolicyVariableExpansions(unittest.TestCase): 107 | def test_var_expansion(self): 108 | self.assertTrue(_matches_after_expansion( 109 | 'arn:aws:iam::000000000000:user/test', 110 | 'arn:aws:iam::000000000000:user/${aws:username}', 111 | CaseInsensitiveDict({'aws:username': 'test'}) 112 | )) 113 | 114 | def test_asterisk_expansion(self): 115 | self.assertTrue(_matches_after_expansion( 116 | 'test-123', 117 | 'test*', 118 | None, 119 | )) 120 | self.assertTrue(_matches_after_expansion( 121 | 'test', 122 | 'test*', 123 | None, 124 | )) 125 | self.assertFalse(_matches_after_expansion( 126 | 'tset', 127 | 'test*', 128 | None, 129 | )) 130 | 131 | def test_qmark_expansion(self): 132 | self.assertTrue(_matches_after_expansion( 133 | 'test-1', 134 | 'test-?', 135 | None, 136 | )) 137 | self.assertFalse(_matches_after_expansion( 138 | 'test1', 139 | 'test-?', 140 | None, 141 | )) 142 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Changes in this Fork 2 | 3 | * Preset query to determine external access for an account: `pmapper --account ACCOUNTID query -s 'preset externalaccess'` 4 | * `externalaccess` subcommand for `orgs` to determine internal-account and external access for accounts in an organization: `pmapper orgs externalaccess --org ORGID` 5 | * `identitycenter` subcommand for `orgs` to ingest Identity Center users, groups, and account assignments: `pmapper orgs identitycenter --org ORGID` 6 | * `neo4j` command to load data into a neo4j database. Limited queries are supplied in `NEO4J_README.md` 7 | * Identity Center information needs to be first collected if you want it loaded. 8 | * When run for a single account it will generate nodes and edges for access granted to external accounts 9 | * When run for an org, it will differentiate between cross-account access and external access, and create nodes for external accounts 10 | * Performance optimizations in local policy simulation 11 | * Multiprocessing for edge identification 12 | 13 | # Principal Mapper 14 | 15 | Principal Mapper (PMapper) is a script and library for identifying risks in the configuration of AWS Identity and 16 | Access Management (IAM) for an AWS account or an AWS organization. It models the different IAM Users and Roles in an 17 | account as a directed graph, which enables checks for privilege escalation and for alternate paths an attacker could 18 | take to gain access to a resource or action in AWS. 19 | 20 | PMapper includes a querying mechanism that uses a local simulation of AWS's authorization behavior. 21 | When running a query to determine if a principal has access to a certain action/resource, PMapper also checks if the 22 | user or role could access other users or roles that have access to that action/resource. This catches scenarios such as 23 | when a user doesn't have permission to read an S3 object, but could launch an EC2 instance that can read the S3 object. 24 | 25 | Additional information can be found in [the project wiki](https://github.com/nccgroup/PMapper/wiki). 26 | 27 | # Installation 28 | 29 | ## Requirements 30 | 31 | Principal Mapper is built using the `botocore` library and Python 3.5+. Principal Mapper 32 | also requires `pydot` (available on `pip`), and `graphviz` (available on Windows, macOS, and Linux from 33 | https://graphviz.org/ ). 34 | 35 | ## Installation from Pip 36 | 37 | ~~~bash 38 | pip install principalmapper 39 | ~~~ 40 | 41 | ## Installation From Source Code 42 | 43 | Clone the repository: 44 | 45 | ~~~bash 46 | git clone git@github.com:nccgroup/PMapper.git 47 | ~~~ 48 | 49 | Then install with Pip: 50 | 51 | ~~~bash 52 | cd PMapper 53 | pip install . 54 | ~~~ 55 | 56 | ## Using Docker 57 | 58 | _(After cloning from source)_ 59 | 60 | ~~~bash 61 | cd PMapper 62 | docker build -t $TAG . 63 | docker run -it $TAG 64 | ~~~ 65 | 66 | You can use `-e|--env` or `--env-file` to pass the `AWS_*` environment variables for credentials when calling 67 | `docker run ...`, or use `-v` to mount your `~/.aws/` directory and use the `AWS_CONFIG_FILE` and `AWS_SHARED_CREDENTIALS_FILE` environment variables. 68 | The current Dockerfile should put you into a shell with `pmapper -h` ready to go as well as 69 | `graphviz` already installed. 70 | 71 | # Usage 72 | 73 | See the [Getting Started Page](https://github.com/nccgroup/PMapper/wiki/Getting-Started) in the wiki for more information 74 | on how to use PMapper via command-line. There are also pages with full details on all command-line functions and 75 | the library code. 76 | 77 | Here's a quick example: 78 | 79 | ```bash 80 | # Create a graph for the account, accessed through AWS CLI profile "skywalker" 81 | pmapper --profile skywalker graph create 82 | # [... graph-creation output goes here ...] 83 | 84 | # Run a query to see who can make IAM Users 85 | $ pmapper --profile skywalker query 'who can do iam:CreateUser' 86 | # [... query output goes here ...] 87 | 88 | # Run a query to see who can launch a big expensive EC2 instance, aside from "admin" users 89 | $ pmapper --account 000000000000 argquery -s --action 'ec2:RunInstances' --condition 'ec2:InstanceType=c6gd.16xlarge' 90 | # [... query output goes here ...] 91 | 92 | # Run the privilege escalation preset query, skip reporting current "admin" users 93 | $ pmapper --account 000000000000 query -s 'preset privesc *' 94 | # [... privesc report goes here ...] 95 | 96 | # Create an SVG representation of the admins/privescs/inter-principal access 97 | $ pmapper --account 000000000000 visualize --filetype svg 98 | # [... information output goes here, file created ...] 99 | ``` 100 | 101 | Note the use of `--profile`, which should behave the same as the AWS CLI. Also, later calls with 102 | `query`/`argquery`/`visualize` use an `--account` arg which just shortcuts around checking which account to work 103 | with (otherwise PMapper makes an API call to determine that). 104 | 105 | Here's an example of the visualization: 106 | 107 | ![](examples/example-viz.png) 108 | 109 | And again when using `--only-privesc`: 110 | 111 | ![](examples/example-privesc-only-viz.svg) 112 | 113 | # Contributions 114 | 115 | 100% welcome and appreciated. Please coordinate through [issues](https://github.com/nccgroup/PMapper/issues) before 116 | starting and target pull-requests at the current development branch (typically of the form `vX.Y.Z-dev`). 117 | 118 | # License 119 | 120 | Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 121 | 122 | Principal Mapper is free software: you can redistribute it and/or modify 123 | it under the terms of the GNU Affero General Public License as published by 124 | the Free Software Foundation, either version 3 of the License, or 125 | (at your option) any later version. 126 | 127 | Principal Mapper is distributed in the hope that it will be useful, 128 | but WITHOUT ANY WARRANTY; without even the implied warranty of 129 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 130 | GNU Affero General Public License for more details. 131 | 132 | You should have received a copy of the GNU Affero General Public License 133 | along with Principal Mapper. If not, see . -------------------------------------------------------------------------------- /examples/graph_from_cf_template.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) NCC Group and Erik Steringer 2021. This file is part of Principal Mapper. 2 | # 3 | # Principal Mapper is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU Affero General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # Principal Mapper is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with Principal Mapper. If not, see . 15 | 16 | """This is an example Python 3 script that creates a Graph object based on the contents of an 17 | AWS CloudFormation Template 18 | 19 | The goal of this script is to help enable 'push-left' and get value out of PMapper earlier 20 | in the infrastructure lifecycle. 21 | 22 | Future improvements: 23 | 24 | * Support all resource types that PMapper supports along with potential edges 25 | * Support other infra-as-code options (Terraform) 26 | """ 27 | 28 | import argparse 29 | import json 30 | 31 | import yaml 32 | 33 | import principalmapper 34 | from principalmapper.common import Graph, Node 35 | from principalmapper.graphing import gathering, graph_actions, iam_edges, sts_edges 36 | 37 | 38 | def _resolve_string(resources, element) -> str: 39 | """Given a thing that can be an object or string, turn it into a string. Handles stuff like 40 | { "Fn::GetAtt": "..." } and turns it into a string.""" 41 | raise NotImplementedError('TODO: implement string handling/resolution') 42 | 43 | 44 | def _generate_iam_id(node_type: str, counter: int) -> str: 45 | """Internal method to generate fake IDs for IAM resources. Format is derived from 46 | https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_identifiers.html 47 | """ 48 | 49 | if node_type == 'user': 50 | return 'AIDA{0:016d}'.format(counter) 51 | elif node_type == 'role': 52 | return 'AROA{0:016d}'.format(counter) 53 | elif node_type == 'group': 54 | return 'AGPA{0:016d}'.format(counter) 55 | elif node_type == 'policy': 56 | return 'ANPA{0:016d}'.format(counter) 57 | else: 58 | raise ValueError('Unexpected value {} for node_type param'.format(node_type)) 59 | 60 | 61 | def main(): 62 | """Body of the script.""" 63 | 64 | # handle arguments 65 | parser = argparse.ArgumentParser() 66 | parser.add_argument('--account', default='000000000000', help='The account ID to assign the simulated Graph') 67 | 68 | file_arg_group = parser.add_mutually_exclusive_group(required=True) 69 | file_arg_group.add_argument('--json', help='The CloudFormation JSON template file to read from') 70 | file_arg_group.add_argument('--yaml', help='The CloudFormation YAML template file to read from') 71 | parsed_args = parser.parse_args() 72 | 73 | # Parse file 74 | if parsed_args.json: 75 | print('[+] Loading file {}'.format(parsed_args.json)) 76 | fd = open(parsed_args.json) 77 | data = json.load(fd) 78 | else: 79 | print('[+] Loading file {}'.format(parsed_args.yaml)) 80 | fd = open(parsed_args.yaml) 81 | data = yaml.safe_load(fd) 82 | fd.close() 83 | 84 | # Create metadata 85 | metadata = { 86 | 'account_id': parsed_args.account, 87 | 'pmapper_version': principalmapper.__version__ 88 | } 89 | 90 | print('[+] Building a Graph object for an account with ID {}'.format(metadata['account_id'])) 91 | 92 | if 'Resources' not in data: 93 | print('[!] Missing required template element "Resources"') 94 | return -1 95 | 96 | # Create space to stash all the data we generate 97 | groups = [] 98 | policies = [] 99 | nodes = [] 100 | 101 | # Handle data from IAM 102 | iam_id_counter = 0 103 | template_resources = data['Resources'] 104 | # TODO: Handle policies to start 105 | # TODO: Handle groups 106 | for logical_id, contents in template_resources.items(): 107 | # Get data on IAM Users and Roles 108 | if contents['Type'] == 'AWS::IAM::User': 109 | properties = contents['Properties'] 110 | node_path = '/' if 'Path' not in properties else properties['Path'] 111 | node_arn = 'arn:aws:iam::{}:user{}'.format( 112 | metadata['account_id'], 113 | '{}{}'.format(node_path, properties['UserName']) 114 | ) 115 | print('[+] Adding user {}'.format(node_arn)) 116 | nodes.append( 117 | Node( 118 | node_arn, 119 | _generate_iam_id('user', iam_id_counter), 120 | [], # TODO: add policy handling 121 | [], # TODO: add group handling 122 | None, 123 | None, 124 | 0, # TODO: fix access keys stuff 125 | False, # TODO: fix password handling 126 | False, # TODO: implement admin checks 127 | None, # TODO: handle permission boundaries 128 | False, # TODO: handle MFA stuff in CF template reading 129 | {} # TODO: add tag handling 130 | ) 131 | ) 132 | iam_id_counter += 1 133 | 134 | elif contents['Type'] == 'AWS::IAM::Role': 135 | properties = contents['Properties'] 136 | # TODO: finish out roles 137 | 138 | # TODO: update access keys for users 139 | 140 | # Sort out administrative principals 141 | gathering.update_admin_status(nodes) 142 | 143 | # Create Edges 144 | edges = iam_edges.generate_edges_locally(nodes) + sts_edges.generate_edges_locally(nodes) 145 | 146 | # Create our graph and finish 147 | graph = Graph(nodes, edges, policies, groups, metadata) 148 | graph_actions.print_graph_data(graph) 149 | 150 | 151 | if __name__ == '__main__': 152 | main() 153 | -------------------------------------------------------------------------------- /tests/test_permissions_boundaries.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 2 | # 3 | # Principal Mapper is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU Affero General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # Principal Mapper is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with Principal Mapper. If not, see . 15 | 16 | """Test functions for permissions boundaries assigned to IAM Users/Roles.""" 17 | 18 | import logging 19 | import unittest 20 | 21 | from tests.build_test_graphs import * 22 | from tests.build_test_graphs import _build_user_with_policy 23 | 24 | from principalmapper.common import Policy 25 | from principalmapper.querying.local_policy_simulation import resource_policy_authorization, ResourcePolicyEvalResult 26 | from principalmapper.querying.query_interface import local_check_authorization, local_check_authorization_full 27 | 28 | 29 | class LocalPermissionsBoundaryHandlingTests(unittest.TestCase): 30 | """Test cases to ensure that Principal Mapper correctly handles permission boundaries: 31 | 32 | https://docs.aws.amazon.com/IAM/latest/UserGuide/access_policies_boundaries.html 33 | """ 34 | 35 | def test_permissions_boundary_no_resource_policy(self): 36 | """In the case of no resource policy, the effective permissions are the "intersection" of the caller's 37 | identity policies + the boundary policy. Both the user's identity policies + boundary policy must 38 | permit the API call. A matching deny statement in either set will deny the whole call. 39 | """ 40 | boundary = Policy( 41 | 'arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess', 42 | 'AmazonS3ReadOnlyAccess', 43 | { 44 | "Version": "2012-10-17", 45 | "Statement": [ 46 | { 47 | "Action": [ 48 | "s3:Get*", 49 | "s3:List*" 50 | ], 51 | "Resource": "*", 52 | "Effect": "Allow" 53 | } 54 | ] 55 | } 56 | ) 57 | 58 | iam_user_1 = _build_user_with_policy( 59 | { 60 | 'Version': '2012-10-17', 61 | 'Statement': [{ 62 | 'Effect': 'Allow', 63 | 'Action': '*', 64 | 'Resource': '*' 65 | }] 66 | }, 67 | 'admin_policy', 68 | 'asdf1', 69 | '1' 70 | ) 71 | 72 | iam_user_1.permissions_boundary = boundary 73 | 74 | self.assertTrue( 75 | local_check_authorization(iam_user_1, 's3:GetObject', 'arn:aws:s3:::bucket/object', {}) 76 | ) 77 | 78 | self.assertFalse( 79 | local_check_authorization(iam_user_1, 's3:PutObject', 'arn:aws:s3:::bucket/object', {}) 80 | ) 81 | 82 | def test_permissions_boundary_with_resource_policy(self): 83 | boundary_1 = Policy( 84 | 'arn:aws:iam::aws:policy/AssumeJumpRole', 85 | 'AssumeJumpRole', 86 | { 87 | "Version": "2012-10-17", 88 | "Statement": [ 89 | { 90 | "Action": "sts:AssumeRole", 91 | "Resource": "arn:aws:iam::000000000000:role/JumpRole", 92 | "Effect": "Allow" 93 | } 94 | ] 95 | } 96 | ) 97 | 98 | iam_user_1 = _build_user_with_policy( 99 | { 100 | 'Version': '2012-10-17', 101 | 'Statement': [{ 102 | 'Effect': 'Allow', 103 | 'Action': '*', 104 | 'Resource': '*' 105 | }] 106 | }, 107 | 'admin_policy', 108 | 'asdf1', 109 | '1' 110 | ) 111 | 112 | iam_user_1.permissions_boundary = boundary_1 113 | 114 | boundary_2 = Policy( 115 | 'arn:aws:iam::aws:policy/EmptyPolicy', 116 | 'EmptyPolicy', 117 | { 118 | "Version": "2012-10-17", 119 | "Statement": [] 120 | } 121 | ) 122 | 123 | iam_user_2 = _build_user_with_policy( 124 | { 125 | 'Version': '2012-10-17', 126 | 'Statement': [] 127 | }, 128 | 'admin_policy', 129 | 'asdf2', 130 | '2' 131 | ) 132 | 133 | iam_user_2.permissions_boundary = boundary_2 134 | 135 | trust_doc = { 136 | 'Version': '2012-10-17', 137 | 'Statement': [{ 138 | 'Effect': 'Allow', 139 | 'Principal': { 140 | 'AWS': [ 141 | 'arn:aws:iam::000000000000:user/asdf2', 142 | 'arn:aws:iam::000000000000:root' 143 | ] 144 | }, 145 | 'Action': 'sts:AssumeRole' 146 | }] 147 | } 148 | 149 | self.assertTrue( 150 | local_check_authorization_full( 151 | iam_user_1, 152 | 'sts:AssumeRole', 153 | 'arn:aws:iam::000000000000:role/JumpRole', 154 | {}, 155 | trust_doc, 156 | '000000000000' 157 | ) 158 | ) 159 | 160 | self.assertTrue( 161 | local_check_authorization_full( 162 | iam_user_2, 163 | 'sts:AssumeRole', 164 | 'arn:aws:iam::000000000000:role/JumpRole', 165 | {}, 166 | trust_doc, 167 | '000000000000' 168 | ) 169 | ) 170 | -------------------------------------------------------------------------------- /principalmapper/visualizing/graphviz_writer.py: -------------------------------------------------------------------------------- 1 | # Copyright (c) NCC Group and Erik Steringer 2020. This file is part of Principal Mapper. 2 | # 3 | # Principal Mapper is free software: you can redistribute it and/or modify 4 | # it under the terms of the GNU Affero General Public License as published by 5 | # the Free Software Foundation, either version 3 of the License, or 6 | # (at your option) any later version. 7 | # 8 | # Principal Mapper is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU Affero General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU Affero General Public License 14 | # along with Principal Mapper. If not, see . 15 | 16 | from typing import Dict, List, Optional 17 | 18 | import pydot 19 | 20 | from principalmapper.common import Graph, Node, Edge 21 | from principalmapper.querying.presets.privesc import can_privesc 22 | from principalmapper.querying.presets.serviceaccess import compose_service_access_map 23 | 24 | 25 | def write_standard_graphviz(graph: Graph, filepath: str, file_format: str, with_services: Optional[bool] = False) -> None: 26 | """The function to generate the standard visualization with a Graphviz-generated file: this is all the nodes 27 | with the admins/privesc highlights in blue/red respectively.""" 28 | 29 | # Load graph data into pydot 30 | pydg = pydot.Dot( 31 | graph_type='digraph', 32 | graph_name='Principal Mapper Visualization: {}'.format(graph.metadata['account_id']), 33 | overlap='scale', 34 | layout='neato', 35 | concentrate='true', 36 | splines='true' 37 | ) 38 | pyd_nd = {} 39 | 40 | # Draw standard nodes and edges: users/roles 41 | for node in graph.nodes: 42 | if node.is_admin: 43 | color = '#BFEFFF' 44 | elif can_privesc(graph, node)[0]: 45 | color = '#FADBD8' 46 | else: 47 | color = 'white' 48 | 49 | pyd_nd[node] = pydot.Node(node.searchable_name(), style='filled', fillcolor=color, shape='box') 50 | pydg.add_node(pyd_nd[node]) 51 | 52 | for edge in graph.edges: 53 | if not edge.source.is_admin: 54 | pydg.add_edge(pydot.Edge(pyd_nd[edge.source], pyd_nd[edge.destination])) 55 | 56 | # draw service nodes and edges 57 | if with_services: 58 | sam = compose_service_access_map(graph) 59 | for service in sam.keys(): 60 | pyd_nd[service] = pydot.Node(service, style='filled', fillcolor='#DDFFDD') 61 | pydg.add_node(pyd_nd[service]) 62 | 63 | for service, node_list in sam.items(): 64 | for node in node_list: 65 | pydg.add_edge(pydot.Edge(pyd_nd[service], pyd_nd[node])) 66 | 67 | # and draw 68 | pydg.write(filepath, format=file_format) 69 | 70 | 71 | def write_privesc_graphviz(graph: Graph, filepath: str, file_format: str) -> None: 72 | """The function to generate the privesc-only visualization with a Graphviz-generated file: this is only the 73 | nodes that are admins/privesc with blue/red highlights.""" 74 | 75 | pydg = pydot.Dot( 76 | graph_type='digraph', 77 | overlap='scale', 78 | layout='dot', 79 | splines='ortho', 80 | rankdir='LR', 81 | forcelabels='true' 82 | ) 83 | 84 | pydot_nodes = {} 85 | 86 | # Need to draw in order of "rank", one new subgraph per-rank, using the edge_list length from the privesc method 87 | ranked_nodes = {} 88 | for node in graph.nodes: 89 | if node.is_admin: 90 | if 0 not in ranked_nodes: 91 | ranked_nodes[0] = [] 92 | ranked_nodes[0].append(node) 93 | else: 94 | pe, edge_list = can_privesc(graph, node) 95 | if pe: 96 | if len(edge_list) not in ranked_nodes: 97 | ranked_nodes[len(edge_list)] = [] 98 | ranked_nodes[len(edge_list)].append(node) 99 | 100 | for rank in sorted(ranked_nodes.keys()): 101 | s = pydot.Subgraph(rank='same') 102 | for node in ranked_nodes[rank]: 103 | if node.is_admin: 104 | # just draw the node and nothing more 105 | pydot_node = pydot.Node(node.searchable_name(), style='filled', fillcolor='#BFEFFF', shape='box') 106 | pydot_nodes[node] = pydot_node 107 | s.add_node(pydot_node) 108 | else: 109 | # draw the node + add edge 110 | pe, edge_list = can_privesc(graph, node) 111 | pydot_node = pydot.Node(node.searchable_name(), style='filled', fillcolor='#FADBD8', shape='box') 112 | pydot_nodes[node] = pydot_node 113 | s.add_node(pydot_node) 114 | 115 | edge_to_add = pydot.Edge(node.searchable_name(), edge_list[0].destination.searchable_name(), 116 | xlabel=edge_list[0].short_reason) 117 | pydg.add_edge(edge_to_add) 118 | 119 | pydg.add_subgraph(s) 120 | 121 | # and draw 122 | pydg.write(filepath, format=file_format) 123 | 124 | 125 | def generate_graphviz(graph: Graph, nodes: List[Node], edges: List[Edge], filepath: str, file_format: str) -> None: 126 | """Draws a graph using Graphviz (dot) with a specific set of nodes and edges.""" 127 | 128 | pydg = pydot.Dot( 129 | graph_type='digraph', 130 | overlap='scale', 131 | layout='neato', 132 | splines='true' 133 | ) 134 | pyd_nd = {} 135 | 136 | for node in nodes: 137 | if node.is_admin: 138 | color = '#BFEFFF' 139 | elif can_privesc(graph, node)[0]: 140 | color = '#FADBD8' 141 | else: 142 | color = 'white' 143 | 144 | pyd_nd[node] = pydot.Node(node.searchable_name(), style='filled', fillcolor=color, shape='box') 145 | pydg.add_node(pyd_nd[node]) 146 | 147 | for edge in edges: 148 | if not edge.source.is_admin: 149 | pydg.add_edge(pydot.Edge(pyd_nd[edge.source], pyd_nd[edge.destination], label=edge.short_reason)) 150 | 151 | # and draw 152 | pydg.write(filepath, format=file_format) 153 | -------------------------------------------------------------------------------- /principalmapper/querying/query_result.py: -------------------------------------------------------------------------------- 1 | """Class representation of a query result.""" 2 | 3 | 4 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 5 | # 6 | # Principal Mapper is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Affero General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # Principal Mapper is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU Affero General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU Affero General Public License 17 | # along with Principal Mapper. If not, see . 18 | 19 | import io 20 | import json 21 | import os 22 | from typing import List, Union 23 | 24 | from principalmapper.common import Edge, Node 25 | 26 | 27 | class QueryResult(object): 28 | """Query result object returned by querying methods. The allowed field specifies if the passed Node is authorized 29 | to make the API call. The edge_list field, if not an empty list, specifies which edges the Node must traverse 30 | to make the API call. 31 | 32 | **Change, v1.1.x:** If the edge_list param contains the same node as the node param, it's the special case where 33 | node is an admin, but could not directly call the API with its perms and had to "use" its admin perms to gain the 34 | necessary access to call the API. 35 | """ 36 | def __init__(self, allowed: bool, edge_list: Union[List[Edge], Node], node: Node): 37 | self.allowed = allowed 38 | self.edge_list = edge_list 39 | self.node = node 40 | 41 | def print_result(self, action_param: str, resource_param: str): 42 | """Prints information about the QueryResult object to stdout.""" 43 | if self.allowed: 44 | if isinstance(self.edge_list, Node): 45 | if self.edge_list == self.node: 46 | # node is an Admin but can't directly call the action 47 | print('{} CAN BECOME authorized to call action {} for resource {} THRU its admin privileges'.format( 48 | self.node.searchable_name(), action_param, resource_param 49 | )) 50 | else: 51 | raise ValueError('Improperly-generated QueryResult object: edge_list is a Node but not the input Node') 52 | 53 | elif len(self.edge_list) == 0: 54 | # node itself is auth'd 55 | print('{} IS authorized to call action {} for resource {}'.format( 56 | self.node.searchable_name(), action_param, resource_param 57 | )) 58 | else: 59 | # node is auth'd through other nodes 60 | print('{} CAN call action {} for resource {} THRU {}'.format( 61 | self.node.searchable_name(), action_param, resource_param, 62 | self.edge_list[-1].destination.searchable_name() 63 | )) 64 | 65 | # print the path the node has to take 66 | for edge in self.edge_list: 67 | print(' {}'.format(edge.describe_edge())) 68 | 69 | # print that the end-edge is authorized to make the call 70 | print(' {} IS authorized to call action {} for resource {}'.format( 71 | self.edge_list[-1].destination.searchable_name(), 72 | action_param, 73 | resource_param 74 | )) 75 | else: 76 | print('{} CANNOT call action {} for resource {}'.format( 77 | self.node.searchable_name(), action_param, resource_param 78 | )) 79 | 80 | def write_result(self, action_param: str, resource_param: str, output: io.StringIO): 81 | """Writes information about the QueryResult object to the given IO interface. 82 | 83 | **Change, v1.1.x:** The `output` param is no longer optional. 84 | """ 85 | 86 | if self.allowed: 87 | if isinstance(self.edge_list, Node) and self.edge_list == self.node: 88 | # node is an Admin but can't directly call the action 89 | output.write('{} IS authorized to call action {} for resource {} THRU its admin privileges\n'.format( 90 | self.node.searchable_name(), action_param, resource_param 91 | )) 92 | if len(self.edge_list) == 0: 93 | # node itself is auth'd 94 | output.write('{} IS authorized to call action {} for resource {}\n'.format( 95 | self.node.searchable_name(), action_param, resource_param)) 96 | 97 | else: 98 | # node is auth'd through other nodes 99 | output.write('{} CAN call action {} for resource {} THRU {}\n'.format( 100 | self.node.searchable_name(), action_param, resource_param, 101 | self.edge_list[-1].destination.searchable_name() 102 | )) 103 | 104 | # print the path the node has to take 105 | for edge in self.edge_list: 106 | output.write(' {}\n'.format(edge.describe_edge())) 107 | 108 | # print that the end-edge is authorized to make the call 109 | output.write(' {} IS authorized to call action {} for resource {}\n'.format( 110 | self.edge_list[-1].destination.searchable_name(), 111 | action_param, 112 | resource_param 113 | )) 114 | else: 115 | output.write('{} CANNOT call action {} for resource {}\n'.format( 116 | self.node.searchable_name(), action_param, resource_param 117 | )) 118 | 119 | def as_json(self): 120 | """Produces a JSON representation of this query's result.""" 121 | if isinstance(self.edge_list, Node): 122 | edge_rep = [{ 123 | 'src': self.edge_list.arn, 124 | 'dst': self.edge_list.arn 125 | }] 126 | else: 127 | edge_rep = [] 128 | for edge in self.edge_list: 129 | edge_rep.append({ 130 | 'src': edge.source.arn, 131 | 'dst': edge.destination.arn 132 | }) 133 | return json.dumps({ 134 | 'allowed': self.allowed, 135 | 'node': self.node.arn, 136 | 'edge_list': edge_rep 137 | }) 138 | -------------------------------------------------------------------------------- /principalmapper/querying/argquery_cli.py: -------------------------------------------------------------------------------- 1 | """Code to implement the CLI interface to the argquery component of Principal Mapper""" 2 | 3 | # Copyright (c) NCC Group and Erik Steringer 2020. This file is part of Principal Mapper. 4 | # 5 | # Principal Mapper is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Principal Mapper is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with Principal Mapper. If not, see . 17 | 18 | import os 19 | from argparse import ArgumentParser, Namespace 20 | import json 21 | import logging 22 | 23 | from principalmapper.common import OrganizationTree, Policy 24 | from principalmapper.graphing import graph_actions 25 | from principalmapper.querying import query_utils, query_actions, query_orgs 26 | from principalmapper.util import botocore_tools, arns 27 | from principalmapper.util.storage import get_storage_root 28 | 29 | logger = logging.getLogger(__name__) 30 | 31 | 32 | def provide_arguments(parser: ArgumentParser): 33 | """Given a parser object (which should be a subparser), add arguments to provide a CLI interface to the 34 | argquery component of Principal Mapper. 35 | """ 36 | parser.add_argument( 37 | '-s', 38 | '--skip-admin', 39 | action='store_true', 40 | help='Ignores administrative principals when querying about multiple principals in an account' 41 | ) 42 | parser.add_argument( 43 | '-u', 44 | '--include-unauthorized', 45 | action='store_true', 46 | help='Includes output to say if a given principal is not able to call an action.' 47 | ) 48 | parser.add_argument( 49 | '--principal', 50 | default='*', 51 | help='A string matching one or more IAM users or roles in the account, or use * (the default) to include all' 52 | ) 53 | parser.add_argument( 54 | '--action', 55 | help='An AWS action to test for, allows * wildcards' 56 | ) 57 | parser.add_argument( 58 | '--resource', 59 | default='*', 60 | help='An AWS resource (denoted by ARN) to test for' 61 | ) 62 | parser.add_argument( 63 | '--condition', 64 | action='append', 65 | help='A set of key-value pairs to test specific conditions' 66 | ) 67 | parser.add_argument( 68 | '--preset', 69 | help='A preset query to run' 70 | ) 71 | argquery_rpolicy_args = parser.add_mutually_exclusive_group() 72 | argquery_rpolicy_args.add_argument( 73 | '--with-resource-policy', 74 | action='store_true', 75 | help='Retrieves and includes the resource policy for the resource given by the --resource parameter. ' 76 | 'Handles S3, IAM, SNS, SQS, and KMS.' 77 | ) 78 | argquery_rpolicy_args.add_argument( 79 | '--resource-policy-text', 80 | help='The full text of a resource policy to consider during authorization evaluation.' 81 | ) 82 | parser.add_argument( 83 | '--resource-owner', 84 | help='The account ID of the owner of the resource. Required for S3 objects (which do not have it in the ARN).' 85 | ) 86 | parser.add_argument( 87 | '--session-policy', 88 | help='The full text of a session policy to consider during authorization evaluation.' 89 | ) 90 | parser.add_argument( 91 | '--scps', 92 | action='store_true', 93 | help='When specified, the SCPs that apply to the account are taken into consideration.' 94 | ) 95 | 96 | 97 | def process_arguments(parsed_args: Namespace): 98 | """Given a namespace object generated from parsing args, perform the appropriate tasks. Returns an int 99 | matching expectations set by /usr/include/sysexits.h for command-line utilities.""" 100 | 101 | if parsed_args.account is None: 102 | session = botocore_tools.get_session(parsed_args.profile) 103 | else: 104 | session = None 105 | graph = graph_actions.get_existing_graph(session, parsed_args.account) 106 | logger.debug('Querying against graph {}'.format(graph.metadata['account_id'])) 107 | 108 | # process condition args to generate input dict 109 | conditions = {} 110 | if parsed_args.condition is not None: 111 | for arg in parsed_args.condition: 112 | # split on equals-sign (=), assume first instance separates the key and value 113 | components = arg.split('=') 114 | if len(components) < 2: 115 | print('Format for condition args not matched: =') 116 | return 64 117 | key = components[0] 118 | value = '='.join(components[1:]) 119 | conditions.update({key: value}) 120 | 121 | if parsed_args.with_resource_policy: 122 | resource_policy = query_utils.pull_cached_resource_policy_by_arn( 123 | graph, 124 | parsed_args.resource 125 | ) 126 | elif parsed_args.resource_policy_text: 127 | resource_policy = json.loads(parsed_args.resource_policy_text) 128 | else: 129 | resource_policy = None 130 | 131 | resource_owner = parsed_args.resource_owner 132 | if resource_policy is not None: 133 | if parsed_args.resource_owner is None: 134 | if arns.get_service(resource_policy.arn) == 's3': 135 | raise ValueError('Must supply resource owner (--resource-owner) when including S3 bucket policies ' 136 | 'in a query') 137 | else: 138 | resource_owner = arns.get_account_id(resource_policy.arn) 139 | if isinstance(resource_policy, Policy): 140 | resource_policy = resource_policy.policy_doc 141 | 142 | if parsed_args.scps: 143 | if 'org-id' in graph.metadata and 'org-path' in graph.metadata: 144 | org_tree_path = os.path.join(get_storage_root(), graph.metadata['org-id']) 145 | org_tree = OrganizationTree.create_from_dir(org_tree_path) 146 | scps = query_orgs.produce_scp_list(graph, org_tree) 147 | else: 148 | raise ValueError('Graph for account {} does not have an associated OrganizationTree mapped (need to run ' 149 | '`pmapper orgs create/update` to get that.') 150 | else: 151 | scps = None 152 | 153 | query_actions.argquery(graph, parsed_args.principal, parsed_args.action, parsed_args.resource, conditions, 154 | parsed_args.preset, parsed_args.skip_admin, resource_policy, 155 | resource_owner, parsed_args.include_unauthorized, parsed_args.session_policy, 156 | scps) 157 | 158 | return 0 159 | -------------------------------------------------------------------------------- /principalmapper/graphing/ssm_edges.py: -------------------------------------------------------------------------------- 1 | """Code to identify if a principal in an AWS account can use access to SSM to access other principals.""" 2 | 3 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 4 | # 5 | # Principal Mapper is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Principal Mapper is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with Principal Mapper. If not, see . 17 | 18 | import io 19 | import logging 20 | import os 21 | from typing import List, Optional 22 | 23 | from principalmapper.common import Edge, Node 24 | from principalmapper.graphing.edge_checker import EdgeChecker 25 | from principalmapper.querying import query_interface 26 | from principalmapper.querying.local_policy_simulation import resource_policy_authorization, ResourcePolicyEvalResult 27 | from principalmapper.util import arns 28 | 29 | from multiprocessing import Pool, Manager, cpu_count 30 | from multiprocessing.queues import Queue 31 | from rich.progress import Progress 32 | import time 33 | 34 | 35 | logger = logging.getLogger(__name__) 36 | 37 | 38 | class SSMEdgeChecker(EdgeChecker): 39 | """Class for identifying if SSM can be used by IAM principals to gain access to other IAM principals.""" 40 | 41 | def return_edges(self, nodes: List[Node], region_allow_list: Optional[List[str]] = None, 42 | region_deny_list: Optional[List[str]] = None, scps: Optional[List[List[dict]]] = None, 43 | client_args_map: Optional[dict] = None) -> List[Edge]: 44 | """Fulfills expected method return_edges. If session object is None, runs checks in offline mode.""" 45 | 46 | logger.info('Generating Edges based on SSM') 47 | result = generate_edges_locally(nodes, scps) 48 | 49 | for edge in result: 50 | logger.info("Found new edge: {}".format(edge.describe_edge())) 51 | 52 | return result 53 | 54 | 55 | def generate_edges_locally(nodes: List[Node], scps: Optional[List[List[dict]]] = None) -> List[Edge]: 56 | """Generates and returns Edge objects. It is possible to use this method if you are operating offline (infra-as-code). 57 | """ 58 | 59 | edges = [] 60 | # check if destination is a role with an instance profile 61 | role_nodes = [node for node in nodes if ':role/' in node.arn and node.instance_profile] 62 | 63 | if not role_nodes: 64 | return [] 65 | 66 | total_nodes = len(role_nodes) 67 | 68 | num_processes = max(cpu_count() - 1, 1) # Number of CPU cores minus one, but at least 1 69 | base_batch_size = len(role_nodes) // num_processes 70 | remainder = len(role_nodes) % num_processes 71 | batch_size = base_batch_size + (1 if remainder > 0 else 0) 72 | 73 | with Manager() as manager: 74 | progress_queue = manager.Queue() 75 | 76 | # Create batches of nodes 77 | batches = [role_nodes[i:i + batch_size] for i in range(0, len(role_nodes), batch_size)] 78 | 79 | with Pool(processes=num_processes) as pool: 80 | pool_result = pool.starmap_async(process_batch, [(batch, nodes, progress_queue, scps) for batch in batches]) 81 | 82 | with Progress() as progress: 83 | task = progress.add_task("[green]Processing SSM edges...", total=total_nodes) 84 | 85 | while not pool_result.ready(): 86 | try: 87 | while not progress_queue.empty(): 88 | progress.advance(task, progress_queue.get_nowait()) 89 | time.sleep(0.1) 90 | except KeyboardInterrupt: 91 | pool.terminate() 92 | break 93 | 94 | results = pool_result.get() 95 | for result in results: 96 | edges.extend(result) 97 | 98 | return edges 99 | 100 | def process_batch(batch: List[Node], nodes: List[Node], progress_queue: Queue, scps: Optional[List[List[dict]]] = None): 101 | 102 | result = [] 103 | 104 | for node_destination in batch: 105 | # check if the destination can be assumed by EC2 106 | sim_result = resource_policy_authorization( 107 | 'ec2.amazonaws.com', 108 | arns.get_account_id(node_destination.arn), 109 | node_destination.trust_policy, 110 | 'sts:AssumeRole', 111 | node_destination.arn, 112 | {}, 113 | ) 114 | 115 | if sim_result != ResourcePolicyEvalResult.SERVICE_MATCH: 116 | progress_queue.put(1) 117 | continue # EC2 wasn't auth'd to assume the role 118 | 119 | # at this point, we make an assumption that some instance is operating with the given instance profile 120 | # we assume if the role can call ssmmessages:CreateControlChannel, anyone with ssm perms can access it 121 | if not query_interface.local_check_authorization(node_destination, 'ssmmessages:CreateControlChannel', '*', {}): 122 | progress_queue.put(1) 123 | continue 124 | 125 | for node_source in nodes: 126 | # skip self-access checks 127 | if node_source == node_destination: 128 | continue 129 | 130 | # check if source is an admin, if so it can access destination but this is not tracked via an Edge 131 | if node_source.is_admin: 132 | continue 133 | 134 | # so if source can call ssm:SendCommand or ssm:StartSession, it's an edge 135 | cmd_auth_res, mfa_res_1 = query_interface.local_check_authorization_handling_mfa( 136 | node_source, 137 | 'ssm:SendCommand', 138 | '*', 139 | {}, 140 | ) 141 | 142 | if cmd_auth_res: 143 | reason = 'can call ssm:SendCommand to access an EC2 instance with access to' 144 | if mfa_res_1: 145 | reason = '(Requires MFA) ' + reason 146 | result.append(Edge(node_source, node_destination, reason, 'SSM')) 147 | 148 | sesh_auth_res, mfa_res_2 = query_interface.local_check_authorization_handling_mfa( 149 | node_source, 150 | 'ssm:StartSession', 151 | '*', 152 | {}, 153 | ) 154 | 155 | if sesh_auth_res: 156 | reason = 'can call ssm:StartSession to access an EC2 instance with access to' 157 | if mfa_res_2: 158 | reason = '(Requires MFA) ' + reason 159 | result.append(Edge(node_source, node_destination, reason, 'SSM')) 160 | 161 | progress_queue.put(1) 162 | 163 | return result 164 | -------------------------------------------------------------------------------- /principalmapper/__main__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides a command-line interface to use the principalmapper library 3 | """ 4 | 5 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 6 | # 7 | # Principal Mapper is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Principal Mapper is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with Principal Mapper. If not, see . 19 | 20 | import argparse 21 | import logging 22 | import sys 23 | 24 | from principalmapper.analysis import cli as analysis_cli 25 | from principalmapper.graphing import graph_cli 26 | from principalmapper.graphing import orgs_cli 27 | from principalmapper.querying import query_cli 28 | from principalmapper.querying import argquery_cli 29 | from principalmapper.querying import repl_cli 30 | from principalmapper.visualizing import cli as visualizing_cli 31 | from principalmapper.neo4j import neo4j_cli 32 | 33 | logger = logging.getLogger(__name__) 34 | 35 | 36 | def main() -> int: 37 | """Point of entry for command-line""" 38 | argument_parser = argparse.ArgumentParser(prog='pmapper') 39 | argument_parser.add_argument( 40 | '--profile', 41 | help='The AWS CLI (botocore) profile to use to call the AWS API.' 42 | ) # Note: do NOT set the default, we want to know if the profile arg was specified or not 43 | argument_parser.add_argument( 44 | '--account', 45 | help='When running offline operations, this parameter determines which account to act against.' 46 | ) 47 | argument_parser.add_argument( 48 | '--debug', 49 | action='store_true', 50 | help='Produces debug-level output of the underlying Principal Mapper library during execution.' 51 | ) 52 | 53 | # Create subparser for various subcommands 54 | subparser = argument_parser.add_subparsers( 55 | title='subcommand', 56 | description='The subcommand to use among this suite of tools', 57 | dest='picked_cmd', 58 | help='Select a subcommand to execute' 59 | ) 60 | 61 | # Graph subcommand 62 | graphparser = subparser.add_parser( 63 | 'graph', 64 | description='Obtains information about a specific AWS account\'s use of IAM for analysis.', 65 | help='Pulls information for an AWS account\'s use of IAM.' 66 | ) 67 | graph_cli.provide_arguments(graphparser) 68 | 69 | # Organizations subcommand 70 | orgsparser = subparser.add_parser( 71 | 'orgs', 72 | description='Obtains information about an AWS Organization for further analysis.', 73 | help='Pulls information for an AWS Organization' 74 | ) 75 | orgs_cli.provide_arguments(orgsparser) 76 | 77 | # Query subcommand 78 | queryparser = subparser.add_parser( 79 | 'query', 80 | description='Displays information corresponding to a roughly human-readable query.', 81 | help='Displays information corresponding to a query' 82 | ) 83 | query_cli.provide_arguments(queryparser) 84 | 85 | # Argquery subcommand 86 | argqueryparser = subparser.add_parser( 87 | 'argquery', 88 | description='Displays information corresponding to a arg-specified query.', 89 | help='Displays information corresponding to a query' 90 | ) 91 | argquery_cli.provide_arguments(argqueryparser) 92 | 93 | # REPL subcommand 94 | replparser = subparser.add_parser( 95 | 'repl', 96 | description='Runs a read-evaluate-print-loop of queries, avoiding the need to read from disk for each query', 97 | help='Runs a REPL for querying' 98 | ) 99 | 100 | # Visualization subcommand 101 | visualizationparser = subparser.add_parser( 102 | 'visualize', 103 | description='Generates an image file to display information about an AWS account', 104 | help='Generates an image representing the AWS account' 105 | ) 106 | visualizing_cli.provide_arguments(visualizationparser) 107 | 108 | # Analysis subcommand 109 | analysisparser = subparser.add_parser( 110 | 'analysis', 111 | description='Analyzes and reports identified issues', 112 | help='Analyzes and reports identified issues' 113 | ) 114 | analysis_cli.provide_arguments(analysisparser) 115 | 116 | # Neo4j subcommand 117 | neo4jparser = subparser.add_parser( 118 | 'neo4j', 119 | description='Ingests data into a Neo4j database', 120 | help='Ingests data into a Neo4j database' 121 | ) 122 | neo4j_cli.provide_arguments(neo4jparser) 123 | 124 | parsed_args = argument_parser.parse_args() 125 | 126 | # setup our outputs here 127 | if parsed_args.debug: 128 | logging.basicConfig( 129 | format='%(asctime)s | %(levelname)8s | %(name)s | %(message)s', 130 | datefmt='%Y-%m-%d %H:%M:%S%z', 131 | level=logging.DEBUG, 132 | handlers=[ 133 | logging.StreamHandler(sys.stdout) 134 | ] 135 | ) 136 | else: 137 | logging.basicConfig( 138 | format='%(asctime)s | %(message)s', 139 | datefmt='%Y-%m-%d %H:%M:%S%z', 140 | level=logging.INFO, 141 | handlers=[ 142 | logging.StreamHandler(sys.stdout) 143 | ] 144 | ) 145 | 146 | # we don't wanna hear from these loggers, even during debugging, due to the sheer volume of output 147 | logging.getLogger('botocore').setLevel(logging.WARNING) 148 | logging.getLogger('urllib3').setLevel(logging.WARNING) 149 | logging.getLogger('principalmapper.querying.query_interface').setLevel(logging.WARNING) 150 | 151 | logger.debug('Parsed args: {}'.format(parsed_args)) 152 | if parsed_args.picked_cmd == 'graph': 153 | return graph_cli.process_arguments(parsed_args) 154 | elif parsed_args.picked_cmd == 'orgs': 155 | return orgs_cli.process_arguments(parsed_args) 156 | elif parsed_args.picked_cmd == 'query': 157 | return query_cli.process_arguments(parsed_args) 158 | elif parsed_args.picked_cmd == 'argquery': 159 | return argquery_cli.process_arguments(parsed_args) 160 | elif parsed_args.picked_cmd == 'repl': 161 | return repl_cli.process_arguments(parsed_args) 162 | elif parsed_args.picked_cmd == 'visualize': 163 | return visualizing_cli.process_arguments(parsed_args) 164 | elif parsed_args.picked_cmd == 'analysis': 165 | return analysis_cli.process_arguments(parsed_args) 166 | elif parsed_args.picked_cmd == 'neo4j': 167 | return neo4j_cli.process_arguments(parsed_args) 168 | 169 | return 64 # /usr/include/sysexits.h 170 | 171 | 172 | if __name__ == '__main__': 173 | sys.exit(main()) 174 | -------------------------------------------------------------------------------- /examples/example-privesc-only-viz.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 9 | 10 | G 11 | 12 | 13 | user/AdminUser 14 | 15 | user/AdminUser 16 | 17 | 18 | user/AdminWork 19 | 20 | user/AdminWork 21 | 22 | 23 | user/TestingSkywalker 24 | 25 | user/TestingSkywalker 26 | 27 | 28 | role/EC2Role-Admin 29 | 30 | role/EC2Role-Admin 31 | 32 | 33 | role/LambdaRole-WithAdmin 34 | 35 | role/LambdaRole-WithAdmin 36 | 37 | 38 | user/EMR-User 39 | 40 | user/EMR-User 41 | 42 | 43 | user/EMR-User->role/EC2Role-Admin 44 | 45 | 46 | EC2 47 | 48 | 49 | user/LambdaFullAccess 50 | 51 | user/LambdaFullAccess 52 | 53 | 54 | user/LambdaFullAccess->role/LambdaRole-WithAdmin 55 | 56 | 57 | Lambda 58 | 59 | 60 | user/PowerUser 61 | 62 | user/PowerUser 63 | 64 | 65 | user/PowerUser->role/EC2Role-Admin 66 | 67 | 68 | EC2 69 | 70 | 71 | role/EC2-Fleet-Manager 72 | 73 | role/EC2-Fleet-Manager 74 | 75 | 76 | role/EC2-Fleet-Manager->role/EC2Role-Admin 77 | 78 | 79 | EC2 80 | 81 | 82 | role/EMR-Service-Role 83 | 84 | role/EMR-Service-Role 85 | 86 | 87 | role/EMR-Service-Role->role/EC2Role-Admin 88 | 89 | 90 | EC2 91 | 92 | 93 | user/EC2Manager 94 | 95 | user/EC2Manager 96 | 97 | 98 | user/EC2Manager->role/EC2-Fleet-Manager 99 | 100 | 101 | EC2 102 | 103 | 104 | 105 | -------------------------------------------------------------------------------- /principalmapper/graphing/sts_edges.py: -------------------------------------------------------------------------------- 1 | """Code to identify if a principal in an AWS account can use access to STS to access other principals.""" 2 | 3 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 4 | # 5 | # Principal Mapper is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Principal Mapper is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with Principal Mapper. If not, see . 17 | 18 | import io 19 | import logging 20 | import os 21 | from typing import List, Optional 22 | 23 | from principalmapper.common import Edge, Node 24 | from principalmapper.graphing.edge_checker import EdgeChecker 25 | from principalmapper.querying import query_interface 26 | from principalmapper.querying.local_policy_simulation import resource_policy_authorization, ResourcePolicyEvalResult, has_matching_statement 27 | from principalmapper.util import arns 28 | 29 | from multiprocessing import Pool, Manager, cpu_count 30 | from multiprocessing.queues import Queue 31 | from rich.progress import Progress 32 | import time 33 | 34 | 35 | logger = logging.getLogger(__name__) 36 | 37 | 38 | class STSEdgeChecker(EdgeChecker): 39 | """Class for identifying if STS can be used by IAM principals to gain access to other IAM principals.""" 40 | 41 | def return_edges(self, nodes: List[Node], region_allow_list: Optional[List[str]] = None, 42 | region_deny_list: Optional[List[str]] = None, scps: Optional[List[List[dict]]] = None, 43 | client_args_map: Optional[dict] = None) -> List[Edge]: 44 | """Fulfills expected method return_edges. If the session object is None, performs checks in offline-mode""" 45 | 46 | result = generate_edges_locally(nodes, scps) 47 | logger.info('Generating Edges based on STS') 48 | 49 | for edge in result: 50 | logger.info("Found new edge: {}".format(edge.describe_edge())) 51 | 52 | return result 53 | 54 | 55 | def generate_edges_locally(nodes: List[Node], scps: Optional[List[List[dict]]] = None) -> List[Edge]: 56 | """Generates and returns Edge objects. It is possible to use this method if you are operating offline (infra-as-code). 57 | """ 58 | edges = [] 59 | role_nodes = [node for node in nodes if ':role/' in node.arn] 60 | total_nodes = len(role_nodes) 61 | 62 | num_processes = max(cpu_count() - 1, 1) # Number of CPU cores minus one, but at least 1 63 | base_batch_size = len(role_nodes) // num_processes 64 | remainder = len(role_nodes) % num_processes 65 | batch_size = base_batch_size + (1 if remainder > 0 else 0) 66 | 67 | with Manager() as manager: 68 | progress_queue = manager.Queue() 69 | 70 | # Create batches of nodes 71 | batches = [role_nodes[i:i + batch_size] for i in range(0, len(role_nodes), batch_size)] 72 | 73 | with Pool(processes=num_processes) as pool: 74 | pool_result = pool.starmap_async(process_batch, [(batch, nodes, progress_queue, scps) for batch in batches]) 75 | 76 | with Progress() as progress: 77 | task = progress.add_task("[green]Processing STS edges...", total=total_nodes) 78 | 79 | while not pool_result.ready(): 80 | try: 81 | while not progress_queue.empty(): 82 | progress.advance(task, progress_queue.get_nowait()) 83 | time.sleep(0.1) 84 | except KeyboardInterrupt: 85 | pool.terminate() 86 | break 87 | 88 | results = pool_result.get() 89 | for result in results: 90 | edges.extend(result) 91 | 92 | return edges 93 | 94 | def process_batch(batch: List[Node], nodes: List[Node], progress_queue: Queue, scps: Optional[List[List[dict]]] = None): 95 | result = [] 96 | for node_destination in batch: 97 | 98 | for node_source in nodes: 99 | # skip self-access checks 100 | if node_source == node_destination: 101 | continue 102 | 103 | # check if source is an admin, if so it can access destination but this is not tracked via an Edge 104 | if node_source.is_admin: 105 | continue 106 | 107 | # Check against resource policy 108 | sim_result = resource_policy_authorization( 109 | node_source, 110 | arns.get_account_id(node_source.arn), 111 | node_destination.trust_policy, 112 | 'sts:AssumeRole', 113 | node_destination.arn, 114 | {}, 115 | ) 116 | 117 | if sim_result == ResourcePolicyEvalResult.DENY_MATCH: 118 | continue # Node was explicitly denied from assuming the role 119 | 120 | if sim_result == ResourcePolicyEvalResult.NO_MATCH: 121 | continue # Resource policy must match for sts:AssumeRole, even in same-account scenarios 122 | 123 | assume_auth, need_mfa = query_interface.local_check_authorization_handling_mfa( 124 | node_source, 'sts:AssumeRole', node_destination.arn, {}, service_control_policy_groups=scps 125 | ) 126 | policy_denies = has_matching_statement( 127 | node_source, 128 | 'Deny', 129 | 'sts:AssumeRole', 130 | node_destination.arn, 131 | {}, 132 | ) 133 | policy_denies_mfa = has_matching_statement( 134 | node_source, 135 | 'Deny', 136 | 'sts:AssumeRole', 137 | node_destination.arn, 138 | { 139 | 'aws:MultiFactorAuthAge': '1', 140 | 'aws:MultiFactorAuthPresent': 'true' 141 | }, 142 | ) 143 | 144 | if assume_auth: 145 | if need_mfa: 146 | reason = '(requires MFA) can access via sts:AssumeRole' 147 | else: 148 | reason = 'can access via sts:AssumeRole' 149 | new_edge = Edge( 150 | node_source, 151 | node_destination, 152 | reason, 153 | 'AssumeRole' 154 | ) 155 | result.append(new_edge) 156 | elif not (policy_denies_mfa and policy_denies) and sim_result == ResourcePolicyEvalResult.NODE_MATCH: 157 | # testing same-account scenario, so NODE_MATCH will override a lack of an allow from iam policy 158 | new_edge = Edge( 159 | node_source, 160 | node_destination, 161 | 'can access via sts:AssumeRole', 162 | 'AssumeRole' 163 | ) 164 | result.append(new_edge) 165 | 166 | progress_queue.put(1) 167 | 168 | return result 169 | -------------------------------------------------------------------------------- /tests/test_org_trees.py: -------------------------------------------------------------------------------- 1 | """Tests for OrganizationTree objects""" 2 | 3 | # Copyright (c) NCC Group and Erik Steringer 2021. This file is part of Principal Mapper. 4 | # 5 | # Principal Mapper is free software: you can redistribute it and/or modify 6 | # it under the terms of the GNU Affero General Public License as published by 7 | # the Free Software Foundation, either version 3 of the License, or 8 | # (at your option) any later version. 9 | # 10 | # Principal Mapper is distributed in the hope that it will be useful, 11 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | # GNU Affero General Public License for more details. 14 | # 15 | # You should have received a copy of the GNU Affero General Public License 16 | # along with Principal Mapper. If not, see . 17 | 18 | import logging 19 | import unittest 20 | 21 | from principalmapper.common import OrganizationTree 22 | from tests.build_test_graphs import * 23 | from tests.build_test_graphs import _build_user_with_policy 24 | from principalmapper.common.nodes import Node 25 | from principalmapper.common.policies import Policy 26 | from principalmapper.querying.query_interface import local_check_authorization_full 27 | 28 | logger = logging.getLogger(__name__) 29 | 30 | 31 | class OrgTreeTests(unittest.TestCase): 32 | def test_admin_cannot_bypass_scps(self): 33 | graph = build_graph_with_one_admin() 34 | principal = graph.nodes[0] 35 | 36 | # SCP list of lists, this would be akin to an account in the root OU with the S3 service denied 37 | scp_collection = [ 38 | [ 39 | { 40 | "Version": "2012-10-17", 41 | "Statement": [ 42 | { 43 | "Effect": "Allow", 44 | "Action": "*", 45 | "Resource": "*" 46 | } 47 | ] 48 | } 49 | ], 50 | [ 51 | { 52 | "Version": "2012-10-17", 53 | "Statement": [ 54 | { 55 | "Effect": "Allow", 56 | "Action": "*", 57 | "Resource": "*" 58 | } 59 | ] 60 | }, 61 | { 62 | "Version": "2012-10-17", 63 | "Statement": [ 64 | { 65 | "Effect": "Deny", 66 | "Action": [ 67 | "s3:*" 68 | ], 69 | "Resource": "*", 70 | "Sid": "Statement1" 71 | } 72 | ] 73 | } 74 | ] 75 | ] 76 | self.assertFalse( 77 | local_check_authorization_full( 78 | principal, 79 | 's3:CreateBucket', 80 | 'arn:aws:s3:::fakebucket', 81 | {}, 82 | None, 83 | None, 84 | scp_collection, 85 | None 86 | ) 87 | ) 88 | self.assertTrue( 89 | local_check_authorization_full( 90 | principal, 91 | 'ec2:RunInstances', 92 | '*', 93 | {}, 94 | None, 95 | None, 96 | scp_collection, 97 | None 98 | ) 99 | ) 100 | 101 | def test_service_linked_role_avoids_scp_restriction(self): 102 | principal = Node( 103 | 'arn:aws:iam::000000000000:role/AWSServiceRoleForSupport', 104 | 'AROAASDF', 105 | [ 106 | Policy( 107 | 'arn:aws:iam::000000000000:role/AWSServiceRoleForS3Support', 108 | 'inline-1', 109 | { 110 | 'Version': '2012-10-17', 111 | 'Statement': [ 112 | { 113 | 'Effect': 'Allow', 114 | 'Action': 's3:*', 115 | 'Resource': '*' 116 | } 117 | ] 118 | } 119 | ) 120 | ], 121 | None, 122 | { 123 | 'Version': '2012-10-17', 124 | 'Statement': [ 125 | { 126 | 'Effect': 'Allow', 127 | 'Action': 'sts:AssumeRole', 128 | 'Principal': { 129 | 'Service': 's3support.amazonaws.com' 130 | } 131 | } 132 | ] 133 | }, 134 | None, 135 | 0, 136 | False, 137 | False, 138 | None, 139 | False, 140 | None 141 | ) 142 | # SCP list of lists, this would be akin to an account in the root OU with the S3 service denied 143 | scp_collection = [ 144 | [ 145 | { 146 | "Version": "2012-10-17", 147 | "Statement": [ 148 | { 149 | "Effect": "Allow", 150 | "Action": "*", 151 | "Resource": "*" 152 | } 153 | ] 154 | } 155 | ], 156 | [ 157 | { 158 | "Version": "2012-10-17", 159 | "Statement": [ 160 | { 161 | "Effect": "Allow", 162 | "Action": "*", 163 | "Resource": "*" 164 | } 165 | ] 166 | }, 167 | { 168 | "Version": "2012-10-17", 169 | "Statement": [ 170 | { 171 | "Effect": "Deny", 172 | "Action": [ 173 | "s3:*" 174 | ], 175 | "Resource": "*", 176 | "Sid": "Statement1" 177 | } 178 | ] 179 | } 180 | ] 181 | ] 182 | self.assertTrue( 183 | local_check_authorization_full( 184 | principal, 185 | 's3:CreateBucket', 186 | 'arn:aws:s3:::fakebucket', 187 | {}, 188 | None, 189 | None, 190 | scp_collection, 191 | None 192 | ), 193 | 'AWSServiceRoleFor... check failed, this role should have access DESPITE the SCPs' 194 | ) 195 | self.assertFalse( 196 | local_check_authorization_full( 197 | principal, 198 | 'ec2:RunInstances', 199 | '*', 200 | {}, 201 | None, 202 | None, 203 | scp_collection, 204 | None 205 | ) 206 | ) 207 | -------------------------------------------------------------------------------- /principalmapper/common/nodes.py: -------------------------------------------------------------------------------- 1 | """Python module containing the Node class and any Node-specific utility functions (currently none).""" 2 | 3 | 4 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 5 | # 6 | # Principal Mapper is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU Affero General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # Principal Mapper is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU Affero General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU Affero General Public License 17 | # along with Principal Mapper. If not, see . 18 | 19 | from typing import List, Optional, Union 20 | 21 | import principalmapper.common.edges 22 | from principalmapper.common.groups import Group 23 | from principalmapper.common.policies import Policy 24 | from principalmapper.util import arns 25 | 26 | 27 | class Node(object): 28 | """The basic Node object: tracks data about the IAM User/Role this Node represents. Includes the ARN, ID, 29 | attached policies (inline or attached), group memberships, trust doc (if IAM Role), instance profiles (if IAM Role), 30 | if a password is active (if IAM User), if there are active access keys (if IAM User), and if the IAM User/Role has 31 | administrative permissions for the account. 32 | 33 | * (1.1.0) Added permissions_boundary support, has_mfa support, tags support""" 34 | 35 | def __init__(self, arn: str, id_value: str, attached_policies: Optional[List[Policy]], 36 | group_memberships: Optional[List[Group]], trust_policy: Optional[dict], 37 | instance_profile: Optional[List[str]], access_keys: int, active_password: bool, is_admin: bool, 38 | permissions_boundary: Optional[Union[str, Policy]], has_mfa: bool, tags: Optional[dict], username: Optional[str] = None): 39 | """Constructor. Expects an ARN and ID value. Validates parameters based on the type of Node (User/Role), 40 | and rejects contradictory arguments like an IAM User with a trust policy. 41 | """ 42 | 43 | resource_value = arns.get_resource(arn) 44 | if arn is None or not (resource_value.startswith('user/') or resource_value.startswith('role/')): 45 | raise ValueError('The parameter arn must be a valid ARN for an IAM user or role.') 46 | self.arn = arn 47 | 48 | # Convert date constructor to null 49 | if username is None: 50 | self.username = "" 51 | else: 52 | self.username = username 53 | 54 | if id_value is None or len(id_value) == 0: 55 | raise ValueError('The parameter id_value must be a non-empty string.') 56 | self.id_value = id_value 57 | 58 | if attached_policies is None: 59 | self.attached_policies = [] 60 | else: 61 | self.attached_policies = attached_policies 62 | 63 | if group_memberships is None: 64 | self.group_memberships = [] 65 | else: 66 | self.group_memberships = group_memberships 67 | 68 | if resource_value.startswith('user/') and trust_policy is not None: 69 | raise ValueError('IAM users do not have trust policies, pass None for the parameter trust_policy.') 70 | if resource_value.startswith('role/') and (trust_policy is None or not isinstance(trust_policy, dict)): 71 | raise ValueError('IAM roles have trust policies, which must be passed as a dictionary in trust_policy') 72 | self.trust_policy = trust_policy # None denotes no trust policy (not a role), {} denotes empty trust policy 73 | 74 | if resource_value.startswith('user/') and instance_profile is not None: 75 | raise ValueError('IAM users do not have instance profiles. Pass None for the parameter instance_profile.') 76 | self.instance_profile = instance_profile 77 | 78 | self.active_password = active_password 79 | 80 | if access_keys is None: 81 | self.access_keys = [] 82 | else: 83 | self.access_keys = access_keys 84 | 85 | self.is_admin = is_admin 86 | 87 | self.permissions_boundary = permissions_boundary # None denotes no permissions boundary, str denotes need to fill in 88 | 89 | self.has_mfa = has_mfa 90 | 91 | if tags is None: 92 | self.tags = {} 93 | else: 94 | self.tags = tags 95 | 96 | self.cache = {} 97 | 98 | self.allowed_external_access = [] 99 | 100 | def searchable_name(self) -> str: 101 | """Creates and caches the searchable name of this node. First it splits the user/.../name into its 102 | parts divided by slashes, then returns the first and last element. The last element is supposed to be unique 103 | within users and roles (RoleName/--role-name or UserName/--user-name parameter when using the API/CLI). 104 | """ 105 | if 'searchable_name' not in self.cache: 106 | components = arns.get_resource(self.arn).split('/') 107 | self.cache['searchable_name'] = "{}/{}".format(components[0], components[-1]) 108 | return self.cache['searchable_name'] 109 | 110 | def get_outbound_edges(self, graph): # -> List[Edge], can't import Edge/Graph in this module 111 | """Creates and caches a collection of edges where this (self) Node is the source.""" 112 | if 'outbound_edges' not in self.cache: 113 | self.cache['outbound_edges'] = [] 114 | if self.is_admin: 115 | for node in graph.nodes: 116 | if node == self: 117 | continue 118 | else: 119 | self.cache['outbound_edges'].append( 120 | principalmapper.common.edges.Edge( 121 | self, node, 'can access through administrative actions', 'Admin' 122 | ) 123 | ) 124 | else: 125 | for edge in graph.edges: 126 | if edge.source == self: 127 | self.cache['outbound_edges'].append(edge) 128 | return self.cache['outbound_edges'] 129 | 130 | def to_dictionary(self) -> dict: 131 | """Creates a dictionary representation of this Node for storage.""" 132 | _pb = self.permissions_boundary 133 | if _pb is not None: 134 | _pb = {'arn': self.permissions_boundary.arn, 'name': self.permissions_boundary.name} 135 | return { 136 | "arn": self.arn, 137 | "id_value": self.id_value, 138 | "attached_policies": [{'arn': policy.arn, 'name': policy.name} for policy in self.attached_policies], 139 | "group_memberships": [group.arn for group in self.group_memberships], 140 | "trust_policy": self.trust_policy, 141 | "instance_profile": self.instance_profile, 142 | "active_password": self.active_password, 143 | "access_keys": self.access_keys, 144 | "is_admin": self.is_admin, 145 | "permissions_boundary": _pb, 146 | "has_mfa": self.has_mfa, 147 | "tags": self.tags, 148 | "username": self.username 149 | } 150 | -------------------------------------------------------------------------------- /principalmapper/querying/presets/externalaccess.py: -------------------------------------------------------------------------------- 1 | """Query preset for testing if a principal can escalate privileges. This is intentionally broken up into multiple 2 | methods to make it usable programmatically. Call can_privesc with a Graph and Node to get results that don't require 3 | parsing text output.""" 4 | 5 | # Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 6 | # 7 | # Principal Mapper is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU Affero General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # Principal Mapper is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU Affero General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU Affero General Public License 18 | # along with Principal Mapper. If not, see . 19 | 20 | import io 21 | import os 22 | from typing import List 23 | 24 | from principalmapper.common import Edge, Node, Graph 25 | from principalmapper.querying.query_utils import get_search_list 26 | from principalmapper.util.arns import get_resource, validate_arn, get_account_id, is_valid_aws_account_id 27 | 28 | def handle_preset_query(graph: Graph, tokens: List[str] = [], skip_admins: bool = False) -> None: 29 | """Handles a human-readable query that's been chunked into tokens, and prints the result.""" 30 | 31 | # Get the nodes we're determining can privesc or not 32 | 33 | external_access_nodes = determine_external_access(graph) 34 | print_external_access_results(external_access_nodes) 35 | 36 | 37 | def determine_external_access(graph: Graph, include_fedenerated: bool = True) -> None: 38 | """Handles a privesc query and writes the result to output.""" 39 | current_account = graph.metadata['account_id'] 40 | nodes = graph.nodes 41 | external_access_roles = [] 42 | for node in nodes: 43 | if not get_resource(node.arn).startswith('role/'): 44 | continue # skip users 45 | saml_provider_arn_prefix = f"arn:aws:iam::{current_account}:saml-provider/" 46 | if not node.trust_policy: 47 | continue 48 | 49 | allows_external_access = False 50 | external_accounts = [] # Hold all the accounts found in this trust policy 51 | for statement in node.trust_policy['Statement']: 52 | _external_accounts = [] # Hold accounts found in this statement 53 | if 'Principal' in statement.keys(): 54 | principal = statement.get('Principal') 55 | 56 | if type(principal) == str or type(principal) == list: 57 | _external_accounts.extend(check_principal_allows_external_access(principal=principal,current_account=current_account)) 58 | 59 | # Its a dict, need to check each type 60 | elif type(principal) == dict: 61 | if 'AWS' in principal.keys(): 62 | _external_accounts.extend(check_principal_allows_external_access(principal=principal['AWS'],current_account=current_account)) 63 | if 'Service' in principal.keys(): 64 | pass 65 | if 'Federated' in principal.keys(): 66 | if include_fedenerated: 67 | _external_accounts.append(principal) 68 | pass 69 | if not any(key in principal for key in ['Federated', 'Service', 'AWS']): 70 | pass # Break on this 71 | else: 72 | pass # Break on this 73 | else: 74 | pass # Break on this 75 | 76 | if _external_accounts: 77 | external_accounts.extend(_external_accounts) 78 | if external_accounts: 79 | node.allowed_external_access.extend(external_accounts) 80 | external_access_roles.append(node) 81 | 82 | return external_access_roles 83 | 84 | def print_external_access_results(nodes: List[Node]) -> None: 85 | for node in nodes: 86 | print(f"{node.searchable_name()} allows access to:") 87 | for account in node.allowed_external_access: 88 | if type(account) == dict: 89 | for k,v in account.items(): 90 | print(f"\t{k}: {v}") 91 | else: 92 | print(f"\t{account}") 93 | pass 94 | 95 | def write_privesc_results(graph: Graph, nodes: List[Node], skip_admins: bool, output: io.StringIO) -> None: 96 | """Handles a privesc query and writes the result to output. 97 | 98 | **Change, v1.1.x:** The `output` param is no longer optional. The `skip_admins` param is no longer optional.""" 99 | for node in nodes: 100 | if skip_admins and node.is_admin: 101 | continue # skip admins 102 | 103 | if node.is_admin: 104 | output.write('{} is an administrative principal\n'.format(node.searchable_name())) 105 | continue 106 | 107 | privesc, edge_list = can_privesc(graph, node) 108 | if privesc: 109 | end_of_list = edge_list[-1].destination 110 | # the node can access this admin node through the current edge list, print this info out 111 | output.write('{} can escalate privileges by accessing the administrative principal {}:\n'.format( 112 | node.searchable_name(), end_of_list.searchable_name())) 113 | for edge in edge_list: 114 | output.write(' {}\n'.format(edge.describe_edge())) 115 | 116 | 117 | def can_privesc(graph: Graph, node: Node) -> (bool, List[Edge]): 118 | """Method for determining if a given Node in a Graph can escalate privileges. 119 | 120 | Returns a bool, List[Edge] tuple. The bool indicates if there is a privesc risk, and the List[Edge] component 121 | describes the path of edges the node would have to take to gain access to the admin node. 122 | """ 123 | edge_lists = get_search_list(graph, node) 124 | searched_nodes = [] 125 | for edge_list in edge_lists: 126 | # check if the node at the end of the list has been looked at yet, skip if so 127 | end_of_list = edge_list[-1].destination 128 | if end_of_list in searched_nodes: 129 | continue 130 | 131 | # add end of list to the searched nodes and do the privesc check 132 | searched_nodes.append(end_of_list) 133 | if end_of_list.is_admin: 134 | return True, edge_list 135 | return False, None 136 | 137 | def check_principal_allows_external_access(principal, current_account): 138 | # Expected to handle principals that take the following form: 139 | # "123456789012" (account id as a string) 140 | # "arn:aws:iam::123456789012:root" (an ARN for a user/role/account) 141 | result = [] 142 | if type(principal) == str: 143 | if is_valid_aws_account_id(principal): 144 | if not principal == current_account: 145 | result.append(principal) 146 | # return [principal] 147 | # return True 148 | else: 149 | pass # Just break on this to confirm working as intended 150 | elif validate_arn(principal): 151 | source_account = get_account_id(principal) 152 | if not source_account == current_account: 153 | result.append(principal) 154 | # return [source_account] 155 | # return True 156 | else: 157 | pass # Allows access to principal(s) in the same account 158 | else: 159 | pass # Just break on this to confirm working as intended 160 | elif type(principal) == list: 161 | for _item in principal: 162 | result.extend(check_principal_allows_external_access(_item, current_account)) 163 | return result --------------------------------------------------------------------------------