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