├── .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 | 
96 |
97 | And again when using `--only-privesc`:
98 |
99 | 
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 |
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 |
--------------------------------------------------------------------------------