├── .github ├── ISSUE_TEMPLATE │ ├── auth-report.md │ ├── bug-report.md │ └── quick-q.md └── workflows │ └── test_PRs.yml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README.md ├── examples ├── example-privesc-only-viz.svg ├── example-viz.png ├── gather_and_analyze.py ├── graph_from_cf_template.py └── sample_cf_template.json ├── pmapper.py ├── principalmapper ├── __init__.py ├── __main__.py ├── analysis │ ├── __init__.py │ ├── cli.py │ ├── find_risks.py │ ├── finding.py │ └── report.py ├── common │ ├── __init__.py │ ├── edges.py │ ├── graphs.py │ ├── groups.py │ ├── nodes.py │ ├── org_trees.py │ └── policies.py ├── graphing │ ├── __init__.py │ ├── autoscaling_edges.py │ ├── cloudformation_edges.py │ ├── codebuild_edges.py │ ├── cross_account_edges.py │ ├── ec2_edges.py │ ├── edge_checker.py │ ├── edge_identification.py │ ├── gathering.py │ ├── graph_actions.py │ ├── graph_cli.py │ ├── iam_edges.py │ ├── lambda_edges.py │ ├── orgs_cli.py │ ├── sagemaker_edges.py │ ├── ssm_edges.py │ └── sts_edges.py ├── querying │ ├── __init__.py │ ├── argquery_cli.py │ ├── local_policy_simulation.py │ ├── presets │ │ ├── __init__.py │ │ ├── clusters.py │ │ ├── connected.py │ │ ├── endgame.py │ │ ├── privesc.py │ │ ├── serviceaccess.py │ │ └── wrongadmin.py │ ├── query_actions.py │ ├── query_cli.py │ ├── query_interface.py │ ├── query_orgs.py │ ├── query_result.py │ ├── query_utils.py │ ├── repl.py │ └── repl_cli.py ├── util │ ├── __init__.py │ ├── arns.py │ ├── botocore_tools.py │ ├── case_insensitive_dict.py │ ├── debug_print.py │ └── storage.py └── visualizing │ ├── __init__.py │ ├── cli.py │ ├── graph_writer.py │ ├── graphml_writer.py │ └── graphviz_writer.py ├── required-permissions.json ├── requirements.txt ├── setup.py └── tests ├── __init__.py ├── build_test_graphs.py ├── test_admin_identification.py ├── test_constructors.py ├── test_cross_account_checks.py ├── test_edge_identification.py ├── test_graph_checking.py ├── test_local_policy_sim.py ├── test_local_querying.py ├── test_org_trees.py ├── test_permissions_boundaries.py └── test_resource_policy_evaluation.py /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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/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 | -------------------------------------------------------------------------------- /.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 venv 18 | /venv* -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Principal Mapper 2 | 3 | Principal Mapper (PMapper) is a script and library for identifying risks in the configuration of AWS Identity and 4 | Access Management (IAM) for an AWS account or an AWS organization. It models the different IAM Users and Roles in an 5 | account as a directed graph, which enables checks for privilege escalation and for alternate paths an attacker could 6 | take to gain access to a resource or action in AWS. 7 | 8 | PMapper includes a querying mechanism that uses a local simulation of AWS's authorization behavior. 9 | When running a query to determine if a principal has access to a certain action/resource, PMapper also checks if the 10 | user or role could access other users or roles that have access to that action/resource. This catches scenarios such as 11 | when a user doesn't have permission to read an S3 object, but could launch an EC2 instance that can read the S3 object. 12 | 13 | Additional information can be found in [the project wiki](https://github.com/nccgroup/PMapper/wiki). 14 | 15 | # Installation 16 | 17 | ## Requirements 18 | 19 | Principal Mapper is built using the `botocore` library and Python 3.5+. Principal Mapper 20 | also requires `pydot` (available on `pip`), and `graphviz` (available on Windows, macOS, and Linux from 21 | https://graphviz.org/ ). 22 | 23 | ## Installation from Pip 24 | 25 | ~~~bash 26 | pip install principalmapper 27 | ~~~ 28 | 29 | ## Installation From Source Code 30 | 31 | Clone the repository: 32 | 33 | ~~~bash 34 | git clone git@github.com:nccgroup/PMapper.git 35 | ~~~ 36 | 37 | Then install with Pip: 38 | 39 | ~~~bash 40 | cd PMapper 41 | pip install . 42 | ~~~ 43 | 44 | ## Using Docker 45 | 46 | _(After cloning from source)_ 47 | 48 | ~~~bash 49 | cd PMapper 50 | docker build -t $TAG . 51 | docker run -it $TAG 52 | ~~~ 53 | 54 | You can use `-e|--env` or `--env-file` to pass the `AWS_*` environment variables for credentials when calling 55 | `docker run ...`, or use `-v` to mount your `~/.aws/` directory and use the `AWS_CONFIG_FILE` and `AWS_SHARED_CREDENTIALS_FILE` environment variables. 56 | The current Dockerfile should put you into a shell with `pmapper -h` ready to go as well as 57 | `graphviz` already installed. 58 | 59 | # Usage 60 | 61 | See the [Getting Started Page](https://github.com/nccgroup/PMapper/wiki/Getting-Started) in the wiki for more information 62 | on how to use PMapper via command-line. There are also pages with full details on all command-line functions and 63 | the library code. 64 | 65 | Here's a quick example: 66 | 67 | ```bash 68 | # Create a graph for the account, accessed through AWS CLI profile "skywalker" 69 | pmapper --profile skywalker graph create 70 | # [... graph-creation output goes here ...] 71 | 72 | # Run a query to see who can make IAM Users 73 | $ pmapper --profile skywalker query 'who can do iam:CreateUser' 74 | # [... query output goes here ...] 75 | 76 | # Run a query to see who can launch a big expensive EC2 instance, aside from "admin" users 77 | $ pmapper --account 000000000000 argquery -s --action 'ec2:RunInstances' --condition 'ec2:InstanceType=c6gd.16xlarge' 78 | # [... query output goes here ...] 79 | 80 | # Run the privilege escalation preset query, skip reporting current "admin" users 81 | $ pmapper --account 000000000000 query -s 'preset privesc *' 82 | # [... privesc report goes here ...] 83 | 84 | # Create an SVG representation of the admins/privescs/inter-principal access 85 | $ pmapper --account 000000000000 visualize --filetype svg 86 | # [... information output goes here, file created ...] 87 | ``` 88 | 89 | Note the use of `--profile`, which should behave the same as the AWS CLI. Also, later calls with 90 | `query`/`argquery`/`visualize` use an `--account` arg which just shortcuts around checking which account to work 91 | with (otherwise PMapper makes an API call to determine that). 92 | 93 | Here's an example of the visualization: 94 | 95 | ![](examples/example-viz.png) 96 | 97 | And again when using `--only-privesc`: 98 | 99 | ![](examples/example-privesc-only-viz.svg) 100 | 101 | # Contributions 102 | 103 | 100% welcome and appreciated. Please coordinate through [issues](https://github.com/nccgroup/PMapper/issues) before 104 | starting and target pull-requests at the current development branch (typically of the form `vX.Y.Z-dev`). 105 | 106 | # License 107 | 108 | Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. 109 | 110 | Principal Mapper is free software: you can redistribute it and/or modify 111 | it under the terms of the GNU Affero General Public License as published by 112 | the Free Software Foundation, either version 3 of the License, or 113 | (at your option) any later version. 114 | 115 | Principal Mapper is distributed in the hope that it will be useful, 116 | but WITHOUT ANY WARRANTY; without even the implied warranty of 117 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 118 | GNU Affero General Public License for more details. 119 | 120 | You should have received a copy of the GNU Affero General Public License 121 | along with Principal Mapper. If not, see . -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /examples/example-viz.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nccgroup/PMapper/91d2e60102bdadf346d77b60d90ddaa4a678f037/examples/example-viz.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/__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 | 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 | parsed_args = argument_parser.parse_args() 117 | 118 | # setup our outputs here 119 | if parsed_args.debug: 120 | logging.basicConfig( 121 | format='%(asctime)s | %(levelname)8s | %(name)s | %(message)s', 122 | datefmt='%Y-%m-%d %H:%M:%S%z', 123 | level=logging.DEBUG, 124 | handlers=[ 125 | logging.StreamHandler(sys.stdout) 126 | ] 127 | ) 128 | else: 129 | logging.basicConfig( 130 | format='%(asctime)s | %(message)s', 131 | datefmt='%Y-%m-%d %H:%M:%S%z', 132 | level=logging.INFO, 133 | handlers=[ 134 | logging.StreamHandler(sys.stdout) 135 | ] 136 | ) 137 | 138 | # we don't wanna hear from these loggers, even during debugging, due to the sheer volume of output 139 | logging.getLogger('botocore').setLevel(logging.WARNING) 140 | logging.getLogger('urllib3').setLevel(logging.WARNING) 141 | logging.getLogger('principalmapper.querying.query_interface').setLevel(logging.WARNING) 142 | 143 | logger.debug('Parsed args: {}'.format(parsed_args)) 144 | if parsed_args.picked_cmd == 'graph': 145 | return graph_cli.process_arguments(parsed_args) 146 | elif parsed_args.picked_cmd == 'orgs': 147 | return orgs_cli.process_arguments(parsed_args) 148 | elif parsed_args.picked_cmd == 'query': 149 | return query_cli.process_arguments(parsed_args) 150 | elif parsed_args.picked_cmd == 'argquery': 151 | return argquery_cli.process_arguments(parsed_args) 152 | elif parsed_args.picked_cmd == 'repl': 153 | return repl_cli.process_arguments(parsed_args) 154 | elif parsed_args.picked_cmd == 'visualize': 155 | return visualizing_cli.process_arguments(parsed_args) 156 | elif parsed_args.picked_cmd == 'analysis': 157 | return analysis_cli.process_arguments(parsed_args) 158 | 159 | return 64 # /usr/include/sysexits.h 160 | 161 | 162 | if __name__ == '__main__': 163 | sys.exit(main()) 164 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 as_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 as_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.as_dictionary() for x in self.findings], 41 | 'source': self.source 42 | } 43 | -------------------------------------------------------------------------------- /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 26 | 27 | # Put submodules into __all__ for neater interface of principalmapper.common 28 | __all__ = ['Node', 'Edge', 'Graph', 'Group', 'Policy', 'OrganizationAccount', 'OrganizationNode', 'OrganizationTree'] 29 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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]]): 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 | 36 | if attached_policies is None: 37 | self.attached_policies = [] 38 | else: 39 | self.attached_policies = attached_policies 40 | 41 | def to_dictionary(self) -> dict: 42 | """Returns a dictionary representation of this object for storage""" 43 | return { 44 | 'arn': self.arn, 45 | 'attached_policies': [{'arn': policy.arn, 'name': policy.name} for policy in self.attached_policies] 46 | } 47 | -------------------------------------------------------------------------------- /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]], num_access_keys: int, active_password: bool, is_admin: bool, 38 | permissions_boundary: Optional[Union[str, Policy]], has_mfa: bool, tags: Optional[dict]): 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 | if id_value is None or len(id_value) == 0: 49 | raise ValueError('The parameter id_value must be a non-empty string.') 50 | self.id_value = id_value 51 | 52 | if attached_policies is None: 53 | self.attached_policies = [] 54 | else: 55 | self.attached_policies = attached_policies 56 | 57 | if group_memberships is None: 58 | self.group_memberships = [] 59 | else: 60 | self.group_memberships = group_memberships 61 | 62 | if resource_value.startswith('user/') and trust_policy is not None: 63 | raise ValueError('IAM users do not have trust policies, pass None for the parameter trust_policy.') 64 | if resource_value.startswith('role/') and (trust_policy is None or not isinstance(trust_policy, dict)): 65 | raise ValueError('IAM roles have trust policies, which must be passed as a dictionary in trust_policy') 66 | self.trust_policy = trust_policy # None denotes no trust policy (not a role), {} denotes empty trust policy 67 | 68 | if resource_value.startswith('user/') and instance_profile is not None: 69 | raise ValueError('IAM users do not have instance profiles. Pass None for the parameter instance_profile.') 70 | self.instance_profile = instance_profile 71 | 72 | self.active_password = active_password 73 | 74 | if num_access_keys is None: 75 | self.access_keys = [] 76 | else: 77 | self.access_keys = num_access_keys 78 | 79 | self.is_admin = is_admin 80 | 81 | self.permissions_boundary = permissions_boundary # None denotes no permissions boundary, str denotes need to fill in 82 | 83 | self.has_mfa = has_mfa 84 | 85 | if tags is None: 86 | self.tags = {} 87 | else: 88 | self.tags = tags 89 | 90 | self.cache = {} 91 | 92 | def searchable_name(self) -> str: 93 | """Creates and caches the searchable name of this node. First it splits the user/.../name into its 94 | parts divided by slashes, then returns the first and last element. The last element is supposed to be unique 95 | within users and roles (RoleName/--role-name or UserName/--user-name parameter when using the API/CLI). 96 | """ 97 | if 'searchable_name' not in self.cache: 98 | components = arns.get_resource(self.arn).split('/') 99 | self.cache['searchable_name'] = "{}/{}".format(components[0], components[-1]) 100 | return self.cache['searchable_name'] 101 | 102 | def get_outbound_edges(self, graph): # -> List[Edge], can't import Edge/Graph in this module 103 | """Creates and caches a collection of edges where this (self) Node is the source.""" 104 | if 'outbound_edges' not in self.cache: 105 | self.cache['outbound_edges'] = [] 106 | if self.is_admin: 107 | for node in graph.nodes: 108 | if node == self: 109 | continue 110 | else: 111 | self.cache['outbound_edges'].append( 112 | principalmapper.common.edges.Edge( 113 | self, node, 'can access through administrative actions', 'Admin' 114 | ) 115 | ) 116 | else: 117 | for edge in graph.edges: 118 | if edge.source == self: 119 | self.cache['outbound_edges'].append(edge) 120 | return self.cache['outbound_edges'] 121 | 122 | def to_dictionary(self) -> dict: 123 | """Creates a dictionary representation of this Node for storage.""" 124 | _pb = self.permissions_boundary 125 | if _pb is not None: 126 | _pb = {'arn': self.permissions_boundary.arn, 'name': self.permissions_boundary.name} 127 | return { 128 | "arn": self.arn, 129 | "id_value": self.id_value, 130 | "attached_policies": [{'arn': policy.arn, 'name': policy.name} for policy in self.attached_policies], 131 | "group_memberships": [group.arn for group in self.group_memberships], 132 | "trust_policy": self.trust_policy, 133 | "instance_profile": self.instance_profile, 134 | "active_password": self.active_password, 135 | "access_keys": self.access_keys, 136 | "is_admin": self.is_admin, 137 | "permissions_boundary": _pb, 138 | "has_mfa": self.has_mfa, 139 | "tags": self.tags 140 | } 141 | -------------------------------------------------------------------------------- /principalmapper/common/org_trees.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 json 17 | import logging 18 | import os 19 | import os.path 20 | from typing import List, Optional, Tuple 21 | 22 | from principalmapper.common import Edge 23 | from principalmapper.common.policies import Policy 24 | 25 | 26 | logger = logging.getLogger(__name__) 27 | 28 | 29 | class OrganizationAccount(object): 30 | """The OrganizationAccount object represents an account within an AWS Organization.""" 31 | 32 | def __init__(self, account_id: str, scps: List[Policy], tags: Optional[dict]): 33 | self.account_id = account_id 34 | self.scps = scps 35 | if tags is None: 36 | self.tags = {} 37 | else: 38 | self.tags = tags 39 | 40 | def as_dictionary(self) -> dict: 41 | """Returns a dictionary representation of this OrganizationAccount object. Used for serialization to disk. We 42 | only return the SCP ARN since it is stored in a separate file.""" 43 | 44 | return { 45 | 'account_id': self.account_id, 46 | 'scps': [x.arn for x in self.scps], 47 | 'tags': self.tags 48 | } 49 | 50 | 51 | class OrganizationNode(object): 52 | """The OrganizationNode object represents an Organizational Unit which can have its own Service Control Policy as 53 | well as a collection of AWS accounts.""" 54 | 55 | def __init__(self, ou_id: str, ou_name: str, accounts: List[OrganizationAccount], child_nodes: list, 56 | scps: List[Policy], tags: Optional[dict]): 57 | """ 58 | Constructor. Note the self-referential typing. 59 | 60 | :type child_nodes List[OrganizationNode] 61 | """ 62 | self.ou_id = ou_id 63 | self.ou_name = ou_name 64 | self.accounts = accounts 65 | self.child_nodes = child_nodes # type: List[OrganizationNode] 66 | self.scps = scps 67 | if tags is None: 68 | self.tags = {} 69 | else: 70 | self.tags = tags 71 | 72 | def as_dictionary(self) -> dict: 73 | """Returns a dictionary representation of this OrganizationNode object. Used for serialization to disk. We 74 | only return the SCP ARN since it is stored in a separate file.""" 75 | 76 | return { 77 | 'ou_id': self.ou_id, 78 | 'ou_name': self.ou_name, 79 | 'accounts': [x.as_dictionary() for x in self.accounts], 80 | 'child_nodes': [x.as_dictionary() for x in self.child_nodes], 81 | 'scps': [x.arn for x in self.scps], 82 | 'tags': self.tags 83 | } 84 | 85 | 86 | class OrganizationTree(object): 87 | """The OrganizationGraph object represents an AWS Organization, which is a collection of AWS accounts. These 88 | accounts are organized in a hierarchy (we use a tree for this). 89 | """ 90 | 91 | def __init__(self, org_id: str, management_account_id: str, root_ous: List[OrganizationNode], 92 | all_scps: List[Policy], accounts: List[str], edge_list: List[Edge], metadata: dict): 93 | self.org_id = org_id 94 | self.management_account_id = management_account_id 95 | self.root_ous = root_ous 96 | self.all_scps = all_scps 97 | self.accounts = accounts 98 | self.edge_list = edge_list 99 | if 'pmapper_version' not in metadata: 100 | raise ValueError('The pmapper_version key/value (str) is required: {"pmapper_version": "..."}') 101 | self.metadata = metadata 102 | 103 | def as_dictionary(self) -> dict: 104 | """Returns a dictionary representation of this OrganizationTree object. Used for serialization to disk. We 105 | exclude the SCPs and metadata since `save_organization_to_disk` does those in a separate file.""" 106 | 107 | return { 108 | 'org_id': self.org_id, 109 | 'management_account_id': self.management_account_id, 110 | 'root_ous': [x.as_dictionary() for x in self.root_ous], 111 | 'edge_list': [x.to_dictionary() for x in self.edge_list], 112 | 'accounts': self.accounts 113 | } 114 | 115 | def save_organization_to_disk(self, dirpath: str): 116 | """Stores this Organization object as a collection of JSON data to disk, in a standard layout from the 117 | given root directory path. 118 | 119 | If the given path does not exist, we try to create it. 120 | 121 | Structure: 122 | | 123 | |---- metadata.json 124 | |---- scps.json 125 | |---- org_data.json 126 | 127 | The client app (such as __main__.py of principalmapper) will specify where to retrieve the data.""" 128 | 129 | rootpath = dirpath 130 | if not os.path.exists(rootpath): 131 | os.makedirs(rootpath, 0o700) 132 | 133 | metadata_filepath = os.path.join(rootpath, 'metadata.json') 134 | scps_filepath = os.path.join(rootpath, 'scps.json') 135 | org_data_filepath = os.path.join(rootpath, 'org_data.json') 136 | 137 | old_umask = os.umask(0o077) # block rwx for group/all 138 | with open(metadata_filepath, 'w') as f: 139 | json.dump(self.metadata, f, indent=4) 140 | with open(scps_filepath, 'w') as f: 141 | json.dump([x.to_dictionary() for x in self.all_scps], f, indent=4) 142 | with open(org_data_filepath, 'w') as f: 143 | org_data_dict = self.as_dictionary() 144 | json.dump(org_data_dict, f, indent=4) 145 | os.umask(old_umask) 146 | 147 | @classmethod 148 | def create_from_dir(cls, dirpath: str): 149 | """This class method instantiates an OrganizationTree object with the contained 150 | OrganizationNode/OrganizationAccount objects.""" 151 | 152 | # load up the Policy objects 153 | policies = {} 154 | policies_path = os.path.join(dirpath, 'scps.json') 155 | with open(policies_path) as fd: 156 | policies_list = json.load(fd) 157 | for policy_dict_obj in policies_list: 158 | policy_obj = Policy(policy_dict_obj['arn'], policy_dict_obj['name'], policy_dict_obj['policy_doc']) 159 | policies[policy_obj.arn] = policy_obj 160 | 161 | # load up the metadata object 162 | metadata_filepath = os.path.join(dirpath, 'metadata.json') 163 | with open(metadata_filepath) as fd: 164 | metadata_obj = json.load(fd) 165 | 166 | # load the OrganizationX objects 167 | org_datafile_path = os.path.join(dirpath, 'org_data.json') 168 | with open(org_datafile_path) as fd: 169 | org_dictrepr = json.load(fd) 170 | 171 | def _produce_ou(ou_dict: dict) -> OrganizationNode: 172 | return OrganizationNode( 173 | ou_dict['ou_id'], 174 | ou_dict['ou_name'], 175 | [OrganizationAccount(x['account_id'], [policies[y] for y in x['scps']], x['tags']) for x in ou_dict['accounts']], 176 | [_produce_ou(x) for x in ou_dict['child_nodes']], 177 | [policies[x] for x in ou_dict['scps']], 178 | ou_dict['tags'] 179 | ) 180 | 181 | # we have to build the OrganizationNodes first 182 | root_ous = [_produce_ou(x) for x in org_dictrepr['root_ous']] 183 | 184 | return OrganizationTree( 185 | org_dictrepr['org_id'], 186 | org_dictrepr['management_account_id'], 187 | root_ous, 188 | [x for x in policies.values()], 189 | org_dictrepr['accounts'], 190 | org_dictrepr['edge_list'], 191 | metadata_obj 192 | ) 193 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | # check without MFA 59 | auth_result = local_check_authorization_full( 60 | na, 61 | 'sts:AssumeRole', 62 | nb.arn, 63 | conditions, 64 | nb.trust_policy, 65 | arns.get_account_id(nb.arn), 66 | scps 67 | ) 68 | 69 | if auth_result: 70 | return True 71 | 72 | # check with MFA 73 | conditions.update({ 74 | 'aws:MultiFactorAuthAge': '1', 75 | 'aws:MultiFactorAuthPresent': 'true' 76 | }) 77 | auth_result = local_check_authorization_full( 78 | na, 79 | 'sts:AssumeRole', 80 | nb.arn, 81 | conditions, 82 | nb.trust_policy, 83 | arns.get_account_id(nb.arn), 84 | scps 85 | ) 86 | 87 | return auth_result 88 | 89 | def _describe_edge(na, nb) -> str: 90 | """Quick method for generating strings describing edges.""" 91 | return '{} -> {}'.format( 92 | '{}/{}'.format(arns.get_account_id(na.arn), na.searchable_name()), 93 | '{}/{}'.format(arns.get_account_id(nb.arn), nb.searchable_name()) 94 | ) 95 | 96 | for node_a in graph_a.nodes: 97 | for node_b in graph_b.nodes: 98 | # check a -> b 99 | if node_b.searchable_name().startswith('role/'): 100 | if _check_assume_role(graph_a, node_a, graph_b, node_b, scps_a): 101 | logger.info('Found edge: {}'.format(_describe_edge(node_a, node_b))) 102 | result.append(Edge(node_a, node_b, 'can call sts:AssumeRole to access', 'STS')) 103 | 104 | # check b -> a 105 | if node_a.searchable_name().startswith('role/'): 106 | if _check_assume_role(graph_b, node_b, graph_a, node_a, scps_b): 107 | logger.info('Found edge: {}'.format(_describe_edge(node_b, node_a))) 108 | result.append(Edge(node_b, node_a, 'can call sts:AssumeRole to access', 'STS')) 109 | 110 | return result 111 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | 42 | def print_graph_data(graph: Graph) -> None: 43 | """Given a Graph object, prints a small amount of information about the Graph. This fulfills 44 | `pmapper graph display`, and also gets ran after `pmapper graph --create`. 45 | """ 46 | print('Graph Data for Account: {}'.format(graph.metadata['account_id'])) 47 | if 'org-id' in graph.metadata: 48 | print(' Organization: {}'.format(graph.metadata['org-id'])) 49 | print(' OU Path: {}'.format(graph.metadata['org-path'])) 50 | admin_count = 0 51 | for node in graph.nodes: 52 | if node.is_admin: 53 | admin_count += 1 54 | print(' # of Nodes: {} ({} admins)'.format(len(graph.nodes), admin_count)) 55 | print(' # of Edges: {}'.format(len(graph.edges))) 56 | print(' # of Groups: {}'.format(len(graph.groups))) 57 | print(' # of (tracked) Policies: {}'.format(len(graph.policies))) 58 | 59 | 60 | def get_graph_from_disk(location: str) -> Graph: 61 | """Returns a Graph object constructed from data stored on-disk at any location. This basically wraps around the 62 | static method in principalmapper.common.graph named Graph.create_graph_from_local_disk(...). 63 | """ 64 | 65 | return Graph.create_graph_from_local_disk(location) 66 | 67 | 68 | def get_existing_graph(session: Optional[botocore.session.Session], account: Optional[str]) -> Graph: 69 | """Returns a Graph object stored on-disk in a standard location (per-OS, using the get_storage_root utility function 70 | in principalmapper.util.storage). Uses the session/account parameter to choose the directory from under the 71 | standard location. 72 | """ 73 | if account is not None: 74 | logger.debug('Loading graph based on given account id: {}'.format(account)) 75 | graph = get_graph_from_disk(get_default_graph_path(account)) 76 | elif session is not None: 77 | stsclient = session.create_client('sts') 78 | response = stsclient.get_caller_identity() 79 | logger.debug('Loading graph based on sts:GetCallerIdentity result: {}'.format(response['Account'])) 80 | graph = get_graph_from_disk(os.path.join(get_default_graph_path(response['Account']))) 81 | else: 82 | raise ValueError('One of the parameters `account` or `session` must not be None') 83 | return graph 84 | 85 | -------------------------------------------------------------------------------- /principalmapper/graphing/iam_edges.py: -------------------------------------------------------------------------------- 1 | """Code to identify if a principal in an AWS account can use access to IAM to access other principals.""" 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 logging 21 | import os 22 | from typing import List, Optional 23 | 24 | from principalmapper.common import Edge, Node 25 | from principalmapper.graphing.edge_checker import EdgeChecker 26 | from principalmapper.querying import query_interface 27 | 28 | 29 | logger = logging.getLogger(__name__) 30 | 31 | 32 | class IAMEdgeChecker(EdgeChecker): 33 | """Class for identifying if IAM can be used by IAM principals to gain access to other IAM principals.""" 34 | 35 | def return_edges(self, nodes: List[Node], region_allow_list: Optional[List[str]] = None, 36 | region_deny_list: Optional[List[str]] = None, scps: Optional[List[List[dict]]] = None, 37 | client_args_map: Optional[dict] = None) -> List[Edge]: 38 | """Fulfills expected method return_edges.""" 39 | 40 | logger.info('Generating Edges based on IAM') 41 | result = generate_edges_locally(nodes, scps) 42 | 43 | for edge in result: 44 | logger.info("Found new edge: {}\n".format(edge.describe_edge())) 45 | 46 | return result 47 | 48 | 49 | def generate_edges_locally(nodes: List[Node], scps: Optional[List[List[dict]]] = None) -> List[Edge]: 50 | """Generates and returns Edge objects. It is possible to use this method if you are operating offline (infra-as-code). 51 | """ 52 | result = [] 53 | 54 | for node_source in nodes: 55 | for node_destination in nodes: 56 | # skip self-access checks 57 | if node_source == node_destination: 58 | continue 59 | 60 | # check if source is an admin, if so it can access destination but this is not tracked via an Edge 61 | if node_source.is_admin: 62 | continue 63 | 64 | if ':user/' in node_destination.arn: 65 | # Change the user's access keys 66 | access_keys_mfa = False 67 | 68 | create_auth_res, mfa_res = query_interface.local_check_authorization_handling_mfa( 69 | node_source, 70 | 'iam:CreateAccessKey', 71 | node_destination.arn, 72 | {}, 73 | service_control_policy_groups=scps 74 | ) 75 | 76 | if mfa_res: 77 | access_keys_mfa = True 78 | 79 | if node_destination.access_keys == 2: 80 | # can have a max of two access keys, need to delete before making a new one 81 | auth_res, mfa_res = query_interface.local_check_authorization_handling_mfa( 82 | node_source, 83 | 'iam:DeleteAccessKey', 84 | node_destination.arn, 85 | {}, 86 | service_control_policy_groups=scps 87 | ) 88 | if not auth_res: 89 | create_auth_res = False # can't delete target access key, can't generate a new one 90 | if mfa_res: 91 | access_keys_mfa = True 92 | 93 | if create_auth_res: 94 | reason = 'can create access keys to authenticate as' 95 | if access_keys_mfa: 96 | reason = '(MFA required) ' + reason 97 | 98 | result.append( 99 | Edge( 100 | node_source, node_destination, reason, 'IAM' 101 | ) 102 | ) 103 | 104 | # Change the user's password 105 | if node_destination.active_password: 106 | pass_auth_res, mfa_res = query_interface.local_check_authorization_handling_mfa( 107 | node_source, 108 | 'iam:UpdateLoginProfile', 109 | node_destination.arn, 110 | {}, 111 | service_control_policy_groups=scps 112 | ) 113 | else: 114 | pass_auth_res, mfa_res = query_interface.local_check_authorization_handling_mfa( 115 | node_source, 116 | 'iam:CreateLoginProfile', 117 | node_destination.arn, 118 | {}, 119 | service_control_policy_groups=scps 120 | ) 121 | if pass_auth_res: 122 | reason = 'can set the password to authenticate as' 123 | if mfa_res: 124 | reason = '(MFA required) ' + reason 125 | result.append(Edge(node_source, node_destination, reason, 'IAM')) 126 | 127 | if ':role/' in node_destination.arn: 128 | # Change the role's trust doc 129 | update_role_res, mfa_res = query_interface.local_check_authorization_handling_mfa( 130 | node_source, 131 | 'iam:UpdateAssumeRolePolicy', 132 | node_destination.arn, 133 | {}, 134 | service_control_policy_groups=scps 135 | ) 136 | if update_role_res: 137 | reason = 'can update the trust document to access' 138 | if mfa_res: 139 | reason = '(MFA required) ' + reason 140 | result.append(Edge(node_source, node_destination, reason, 'IAM')) 141 | 142 | return result 143 | -------------------------------------------------------------------------------- /principalmapper/graphing/sagemaker_edges.py: -------------------------------------------------------------------------------- 1 | """Code to identify if a principal in an AWS account can use access to SageMaker to access other principals.""" 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 Edge, Node 22 | from principalmapper.graphing.edge_checker import EdgeChecker 23 | from principalmapper.querying import query_interface 24 | from principalmapper.querying.local_policy_simulation import resource_policy_authorization, ResourcePolicyEvalResult 25 | from principalmapper.util import arns 26 | 27 | 28 | logger = logging.getLogger(__name__) 29 | 30 | 31 | class SageMakerEdgeChecker(EdgeChecker): 32 | """Class for identifying if Amazon SageMaker can be used by IAM principals to access other principals. 33 | 34 | TODO: add checks for CreateDomain and related operations 35 | """ 36 | 37 | def return_edges(self, nodes: List[Node], region_allow_list: Optional[List[str]] = None, 38 | region_deny_list: Optional[List[str]] = None, scps: Optional[List[List[dict]]] = None, 39 | client_args_map: Optional[dict] = None) -> List[Edge]: 40 | """fulfills expected method""" 41 | 42 | logger.info('Generating Edges based on SageMaker') 43 | result = generate_edges_locally(nodes, scps) 44 | 45 | for edge in result: 46 | logger.info("Found new edge: {}".format(edge.describe_edge())) 47 | 48 | return result 49 | 50 | 51 | def generate_edges_locally(nodes: List[Node], scps: Optional[List[List[dict]]] = None) -> List[Edge]: 52 | """Generates and returns Edge objects. It is possible to use this method if you are operating offline (infra-as-code). 53 | """ 54 | 55 | result = [] 56 | for node_destination in nodes: 57 | 58 | if ':role/' not in node_destination.arn: 59 | continue # skip if destination is a user and not a role 60 | 61 | sim_result = resource_policy_authorization( 62 | 'sagemaker.amazonaws.com', 63 | arns.get_account_id(node_destination.arn), 64 | node_destination.trust_policy, 65 | 'sts:AssumeRole', 66 | node_destination.arn, 67 | {} 68 | ) 69 | 70 | if sim_result != ResourcePolicyEvalResult.SERVICE_MATCH: 71 | continue # SageMaker is not authorized to assume the role 72 | 73 | for node_source in nodes: 74 | if node_source == node_destination: 75 | continue # skip self-access checks 76 | 77 | if node_source.is_admin: 78 | continue # skip if source is already admin, not tracked via edges 79 | 80 | mfa_needed = False 81 | conditions = {'iam:PassedToService': 'sagemaker.amazonaws.com'} 82 | pass_role_auth, pass_needs_mfa = query_interface.local_check_authorization_handling_mfa( 83 | node_source, 84 | 'iam:PassRole', 85 | node_destination.arn, 86 | conditions, 87 | service_control_policy_groups=scps 88 | ) 89 | if not pass_role_auth: 90 | continue # source node is not authorized to pass the role 91 | 92 | create_notebook_auth, needs_mfa = query_interface.local_check_authorization_handling_mfa( 93 | node_source, 94 | 'sagemaker:CreateNotebookInstance', 95 | '*', 96 | {}, 97 | service_control_policy_groups=scps 98 | ) 99 | 100 | if create_notebook_auth: 101 | new_edge = Edge( 102 | node_source, 103 | node_destination, 104 | '(MFA required) can use SageMaker to launch a notebook and access' if pass_needs_mfa or needs_mfa else 'can use SageMaker to launch a notebook and access', 105 | 'SageMaker' 106 | ) 107 | result.append(new_edge) 108 | 109 | create_training_auth, needs_mfa = query_interface.local_check_authorization_handling_mfa( 110 | node_source, 111 | 'sagemaker:CreateTrainingJob', 112 | '*', 113 | {}, 114 | service_control_policy_groups=scps 115 | ) 116 | 117 | if create_training_auth: 118 | result.append(Edge( 119 | node_source, 120 | node_destination, 121 | '(MFA required) can use SageMaker to create a training job and access' if pass_needs_mfa or needs_mfa else 'can use SageMaker to create a training job and access', 122 | 'SageMaker' 123 | )) 124 | 125 | create_processing_auth, needs_mfa = query_interface.local_check_authorization_handling_mfa( 126 | node_source, 127 | 'sagemaker:CreateProcessingJob', 128 | '*', 129 | {}, 130 | service_control_policy_groups=scps 131 | ) 132 | 133 | if create_processing_auth: 134 | result.append(Edge( 135 | node_source, 136 | node_destination, 137 | '(MFA required) can use SageMaker to create a processing job and access' if pass_needs_mfa or needs_mfa else 'can use SageMaker to create a processing job and access', 138 | 'SageMaker' 139 | )) 140 | 141 | return result 142 | -------------------------------------------------------------------------------- /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 | 30 | logger = logging.getLogger(__name__) 31 | 32 | 33 | class SSMEdgeChecker(EdgeChecker): 34 | """Class for identifying if SSM can be used by IAM principals to gain access to other IAM principals.""" 35 | 36 | def return_edges(self, nodes: List[Node], region_allow_list: Optional[List[str]] = None, 37 | region_deny_list: Optional[List[str]] = None, scps: Optional[List[List[dict]]] = None, 38 | client_args_map: Optional[dict] = None) -> List[Edge]: 39 | """Fulfills expected method return_edges. If session object is None, runs checks in offline mode.""" 40 | 41 | logger.info('Generating Edges based on SSM') 42 | result = generate_edges_locally(nodes, scps) 43 | 44 | for edge in result: 45 | logger.info("Found new edge: {}".format(edge.describe_edge())) 46 | 47 | return result 48 | 49 | 50 | def generate_edges_locally(nodes: List[Node], scps: Optional[List[List[dict]]] = None) -> List[Edge]: 51 | """Generates and returns Edge objects. It is possible to use this method if you are operating offline (infra-as-code). 52 | """ 53 | 54 | result = [] 55 | 56 | for node_destination in nodes: 57 | # check if destination is a role with an instance profile 58 | if ':role/' not in node_destination.arn or node_destination.instance_profile is None: 59 | continue 60 | 61 | # check if the destination can be assumed by EC2 62 | sim_result = resource_policy_authorization( 63 | 'ec2.amazonaws.com', 64 | arns.get_account_id(node_destination.arn), 65 | node_destination.trust_policy, 66 | 'sts:AssumeRole', 67 | node_destination.arn, 68 | {}, 69 | ) 70 | 71 | if sim_result != ResourcePolicyEvalResult.SERVICE_MATCH: 72 | continue # EC2 wasn't auth'd to assume the role 73 | 74 | # at this point, we make an assumption that some instance is operating with the given instance profile 75 | # we assume if the role can call ssmmessages:CreateControlChannel, anyone with ssm perms can access it 76 | if not query_interface.local_check_authorization(node_destination, 'ssmmessages:CreateControlChannel', '*', {}): 77 | continue 78 | 79 | for node_source in nodes: 80 | # skip self-access checks 81 | if node_source == node_destination: 82 | continue 83 | 84 | # check if source is an admin, if so it can access destination but this is not tracked via an Edge 85 | if node_source.is_admin: 86 | continue 87 | 88 | # so if source can call ssm:SendCommand or ssm:StartSession, it's an edge 89 | cmd_auth_res, mfa_res_1 = query_interface.local_check_authorization_handling_mfa( 90 | node_source, 91 | 'ssm:SendCommand', 92 | '*', 93 | {}, 94 | ) 95 | 96 | if cmd_auth_res: 97 | reason = 'can call ssm:SendCommand to access an EC2 instance with access to' 98 | if mfa_res_1: 99 | reason = '(Requires MFA) ' + reason 100 | result.append(Edge(node_source, node_destination, reason, 'SSM')) 101 | 102 | sesh_auth_res, mfa_res_2 = query_interface.local_check_authorization_handling_mfa( 103 | node_source, 104 | 'ssm:StartSession', 105 | '*', 106 | {}, 107 | ) 108 | 109 | if sesh_auth_res: 110 | reason = 'can call ssm:StartSession to access an EC2 instance with access to' 111 | if mfa_res_2: 112 | reason = '(Requires MFA) ' + reason 113 | result.append(Edge(node_source, node_destination, reason, 'SSM')) 114 | 115 | return result 116 | -------------------------------------------------------------------------------- /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 | 30 | logger = logging.getLogger(__name__) 31 | 32 | 33 | class STSEdgeChecker(EdgeChecker): 34 | """Class for identifying if STS can be used by IAM principals to gain access to other IAM principals.""" 35 | 36 | def return_edges(self, nodes: List[Node], region_allow_list: Optional[List[str]] = None, 37 | region_deny_list: Optional[List[str]] = None, scps: Optional[List[List[dict]]] = None, 38 | client_args_map: Optional[dict] = None) -> List[Edge]: 39 | """Fulfills expected method return_edges. If the session object is None, performs checks in offline-mode""" 40 | 41 | result = generate_edges_locally(nodes, scps) 42 | logger.info('Generating Edges based on STS') 43 | 44 | for edge in result: 45 | logger.info("Found new edge: {}".format(edge.describe_edge())) 46 | 47 | return result 48 | 49 | 50 | def generate_edges_locally(nodes: List[Node], scps: Optional[List[List[dict]]] = None) -> List[Edge]: 51 | """Generates and returns Edge objects. It is possible to use this method if you are operating offline (infra-as-code). 52 | """ 53 | 54 | result = [] 55 | for node_destination in nodes: 56 | if ':role/' not in node_destination.arn: 57 | continue # skip non-roles 58 | 59 | for node_source in nodes: 60 | # skip self-access checks 61 | if node_source == node_destination: 62 | continue 63 | 64 | # check if source is an admin, if so it can access destination but this is not tracked via an Edge 65 | if node_source.is_admin: 66 | continue 67 | 68 | # Check against resource policy 69 | sim_result = resource_policy_authorization( 70 | node_source, 71 | arns.get_account_id(node_source.arn), 72 | node_destination.trust_policy, 73 | 'sts:AssumeRole', 74 | node_destination.arn, 75 | {}, 76 | ) 77 | 78 | if sim_result == ResourcePolicyEvalResult.DENY_MATCH: 79 | continue # Node was explicitly denied from assuming the role 80 | 81 | if sim_result == ResourcePolicyEvalResult.NO_MATCH: 82 | continue # Resource policy must match for sts:AssumeRole, even in same-account scenarios 83 | 84 | assume_auth, need_mfa = query_interface.local_check_authorization_handling_mfa( 85 | node_source, 'sts:AssumeRole', node_destination.arn, {}, service_control_policy_groups=scps 86 | ) 87 | policy_denies = has_matching_statement( 88 | node_source, 89 | 'Deny', 90 | 'sts:AssumeRole', 91 | node_destination.arn, 92 | {}, 93 | ) 94 | policy_denies_mfa = has_matching_statement( 95 | node_source, 96 | 'Deny', 97 | 'sts:AssumeRole', 98 | node_destination.arn, 99 | { 100 | 'aws:MultiFactorAuthAge': '1', 101 | 'aws:MultiFactorAuthPresent': 'true' 102 | }, 103 | ) 104 | 105 | if assume_auth: 106 | if need_mfa: 107 | reason = '(requires MFA) can access via sts:AssumeRole' 108 | else: 109 | reason = 'can access via sts:AssumeRole' 110 | new_edge = Edge( 111 | node_source, 112 | node_destination, 113 | reason, 114 | 'AssumeRole' 115 | ) 116 | result.append(new_edge) 117 | elif not (policy_denies_mfa and policy_denies) and sim_result == ResourcePolicyEvalResult.NODE_MATCH: 118 | # testing same-account scenario, so NODE_MATCH will override a lack of an allow from iam policy 119 | new_edge = Edge( 120 | node_source, 121 | node_destination, 122 | 'can access via sts:AssumeRole', 123 | 'AssumeRole' 124 | ) 125 | result.append(new_edge) 126 | 127 | return result 128 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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/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/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/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/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 | 27 | def get_partition(arn: str): 28 | """Returns the partition from a string ARN.""" 29 | return arn.split(':')[1] 30 | 31 | 32 | def get_service(arn: str): 33 | """Returns the service from a string ARN.""" 34 | return arn.split(':')[2] 35 | 36 | 37 | def get_region(arn: str): 38 | """Returns the region from a string ARN.""" 39 | return arn.split(':')[3] 40 | 41 | 42 | def get_account_id(arn: str): 43 | """Returns the account ID from a string ARN.""" 44 | return arn.split(':')[4] 45 | 46 | 47 | def get_resource(arn: str): 48 | """Returns the resource (trailing part) from a string ARN. Note that we're splitting on colons, so we have to 49 | join with colons in case the trailing part uses colon-separators instead of forward-slashes. 50 | """ 51 | return ':'.join(arn.split(':')[5:]) 52 | 53 | 54 | def validate_arn(arn: str) -> bool: 55 | """Returns true if the provided ARN appears to follow the expected structure of an ARN.""" 56 | arn_arr = arn.split(':') 57 | if len(arn_arr) < 6: 58 | return False 59 | if arn_arr[0] != 'arn': 60 | return False 61 | return True 62 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 import Mapping, MutableMapping, OrderedDict 35 | 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/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/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/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/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/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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | botocore > 1.13 2 | packaging 3 | python-dateutil 4 | pydot -------------------------------------------------------------------------------- /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/__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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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, num_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, num_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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------