From 88bad89bd84a20a264165514363e52a84d39e8d7 Mon Sep 17 00:00:00 2001 From: Dustin Rodrigues Date: Tue, 18 Jan 2022 18:27:01 -0500 Subject: [PATCH 01/21] support Python 3.10+ --- principalmapper/util/case_insensitive_dict.py | 6 +++++- setup.py | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/principalmapper/util/case_insensitive_dict.py b/principalmapper/util/case_insensitive_dict.py index ff5ee90..695c036 100644 --- a/principalmapper/util/case_insensitive_dict.py +++ b/principalmapper/util/case_insensitive_dict.py @@ -31,7 +31,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -from collections import Mapping, MutableMapping, OrderedDict +from collections import OrderedDict +try: + from collections import Mapping, MutableMapping +except ImportError: + from collections.abc import Mapping, MutableMapping class CaseInsensitiveDict(MutableMapping): diff --git a/setup.py b/setup.py index ea072fa..d810147 100644 --- a/setup.py +++ b/setup.py @@ -57,6 +57,7 @@ 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', 'Topic :: Security' ], keywords=[ From c36dacc03d936789a009574ba3aa7cfa354c63cc Mon Sep 17 00:00:00 2001 From: Erik Steringer Date: Tue, 25 Jan 2022 17:01:16 -0800 Subject: [PATCH 02/21] first attempt at implementing support for other partitions --- principalmapper/__init__.py | 2 +- principalmapper/analysis/find_risks.py | 10 +- principalmapper/analysis/report.py | 4 +- principalmapper/common/graphs.py | 51 +++++++--- principalmapper/common/org_trees.py | 27 ++++- principalmapper/graphing/autoscaling_edges.py | 14 +-- .../graphing/cloudformation_edges.py | 4 +- principalmapper/graphing/codebuild_edges.py | 4 +- .../graphing/cross_account_edges.py | 2 +- principalmapper/graphing/ec2_edges.py | 2 +- principalmapper/graphing/edge_checker.py | 6 +- .../graphing/edge_identification.py | 9 +- principalmapper/graphing/gathering.py | 98 +++++++++++-------- principalmapper/graphing/graph_actions.py | 29 ++++-- principalmapper/graphing/graph_cli.py | 12 ++- principalmapper/graphing/iam_edges.py | 2 +- principalmapper/graphing/lambda_edges.py | 4 +- principalmapper/graphing/orgs_cli.py | 16 ++- principalmapper/graphing/sagemaker_edges.py | 2 +- principalmapper/graphing/ssm_edges.py | 2 +- principalmapper/graphing/sts_edges.py | 2 +- principalmapper/querying/argquery_cli.py | 2 +- principalmapper/querying/presets/endgame.py | 14 +-- .../querying/presets/wrongadmin.py | 3 +- principalmapper/querying/query_cli.py | 2 +- principalmapper/querying/query_interface.py | 2 +- principalmapper/querying/query_orgs.py | 7 +- principalmapper/querying/query_utils.py | 4 +- principalmapper/util/botocore_tools.py | 10 +- principalmapper/util/storage.py | 15 ++- principalmapper/visualizing/cli.py | 4 +- .../visualizing/graphviz_writer.py | 2 +- requirements.txt | 2 +- setup.py | 2 +- tests/build_test_graphs.py | 2 +- 35 files changed, 244 insertions(+), 129 deletions(-) diff --git a/principalmapper/__init__.py b/principalmapper/__init__.py index c8764d3..4443b7a 100644 --- a/principalmapper/__init__.py +++ b/principalmapper/__init__.py @@ -15,4 +15,4 @@ # You should have received a copy of the GNU Affero General Public License # along with Principal Mapper. If not, see . -__version__ = '1.1.5' +__version__ = '1.2.0' diff --git a/principalmapper/analysis/find_risks.py b/principalmapper/analysis/find_risks.py index 9621439..4e94611 100644 --- a/principalmapper/analysis/find_risks.py +++ b/principalmapper/analysis/find_risks.py @@ -55,7 +55,8 @@ def gen_report(graph: Graph) -> Report: """Generates a Report object with findings and metadata about report-generation""" findings = gen_all_findings(graph) return Report( - graph.metadata['account_id'], + graph.account, + graph.partition, dt.datetime.now(dt.timezone.utc), findings, 'Findings identified using Principal Mapper ({}) from NCC Group: https://github.com/nccgroup/PMapper'.format( @@ -475,7 +476,7 @@ def gen_resources_with_potential_confused_deputies(graph: Graph) -> List[Finding for action in action_list: rpa_result = resource_policy_authorization( service, - graph.metadata['account_id'], + graph.account, policy.policy_doc, action, policy.arn, @@ -523,7 +524,10 @@ def print_report(report: Report) -> None: print('----------------------------------------------------------------') print('# Principal Mapper Findings') print() - print('Findings identified in AWS account {}'.format(report.account)) + if report.partition == 'aws': + print('Findings identified in AWS account {}'.format(report.account)) + else: + print(f'Findings identified in AWS account {report.account} ({report.partition})') print() print('Date and Time: {}'.format(report.date_and_time.isoformat())) print() diff --git a/principalmapper/analysis/report.py b/principalmapper/analysis/report.py index 6bcee70..598debb 100644 --- a/principalmapper/analysis/report.py +++ b/principalmapper/analysis/report.py @@ -26,8 +26,9 @@ class Report: utility function to convert the contents of the report to a dictionary object. """ - def __init__(self, account: str, date_and_time: dt.datetime, findings: List[Finding], source: str): + def __init__(self, account: str, partition: str, date_and_time: dt.datetime, findings: List[Finding], source: str): self.account = account + self.partition = partition self.date_and_time = date_and_time self.findings = findings self.source = source @@ -36,6 +37,7 @@ def as_dictionary(self) -> dict: """Produces a dictionary representing this Report's contents.""" return { 'account': self.account, + 'partition': self.partition, 'date_and_time': self.date_and_time.isoformat(), 'findings': [x.as_dictionary() for x in self.findings], 'source': self.source diff --git a/principalmapper/common/graphs.py b/principalmapper/common/graphs.py index a6ff383..d34c70b 100644 --- a/principalmapper/common/graphs.py +++ b/principalmapper/common/graphs.py @@ -40,21 +40,24 @@ class Graph(object): Graph data to/from files stored on-disk. The actual attributes of each graph/node/edge/policy/group object will remain the same across the same major+minor version of Principal Mapper, so a graph generated in v1.0.0 should be loadable in v1.0.1, but not v1.1.0. + + * **v1.2.0:** Shifted account/partition to arguments of Graph construction """ - def __init__(self, nodes: list = None, edges: list = None, policies: list = None, groups: list = None, - metadata: dict = None): + def __init__(self, nodes: list, edges: list, policies: list, groups: list, account: str, partition: str, + metadata: dict): """Constructor""" - for arg, value in {'nodes': nodes, 'edges': edges, 'policies': policies, 'groups': groups, - 'metadata': metadata}.items(): - if value is None: - raise ValueError('Required constructor argument {} was None'.format(arg)) + + for x in (nodes, edges, policies, groups, account, partition, metadata): + if x is None: + raise ValueError(f'Required argument {x} was None') + self.nodes = nodes self.edges = edges self.policies = policies self.groups = groups - if 'account_id' not in metadata: - raise ValueError('Incomplete metadata input, expected key: "account_id"') + self.account = account + self.partition = partition if 'pmapper_version' not in metadata: raise ValueError('Incomplete metadata input, expected key: "pmapper_version"') self.metadata = metadata @@ -73,6 +76,7 @@ def store_graph_as_json(self, root_directory: str): Structure: | + |---- data.json |---- metadata.json |---- graph/ |-------- nodes.json @@ -88,6 +92,7 @@ def store_graph_as_json(self, root_directory: str): graphdir = os.path.join(rootpath, 'graph') if not os.path.exists(graphdir): os.makedirs(graphdir, 0o700) + regulardatafilepath = os.path.join(rootpath, 'data.json') metadatafilepath = os.path.join(rootpath, 'metadata.json') nodesfilepath = os.path.join(graphdir, 'nodes.json') edgesfilepath = os.path.join(graphdir, 'edges.json') @@ -95,6 +100,8 @@ def store_graph_as_json(self, root_directory: str): groupsfilepath = os.path.join(graphdir, 'groups.json') old_umask = os.umask(0o077) # block rwx for group/all + with open(regulardatafilepath, 'w') as f: + json.dump({'account': self.account, 'partition': self.partition}, f, indent=4) with open(metadatafilepath, 'w') as f: json.dump(self.metadata, f, indent=4) with open(nodesfilepath, 'w') as f: @@ -113,6 +120,7 @@ def create_graph_from_local_disk(cls, root_directory: str): Structure: | + |---- data.json |---- metadata.json |---- graph/ |-------- nodes.json @@ -132,6 +140,7 @@ def create_graph_from_local_disk(cls, root_directory: str): raise ValueError('Did not find file at: {}'.format(rootpath)) graphdir = os.path.join(rootpath, 'graph') metadatafilepath = os.path.join(rootpath, 'metadata.json') + regulardatafilepath = os.path.join(rootpath, 'data.json') nodesfilepath = os.path.join(graphdir, 'nodes.json') edgesfilepath = os.path.join(graphdir, 'edges.json') policiesfilepath = os.path.join(graphdir, 'policies.json') @@ -142,12 +151,16 @@ def create_graph_from_local_disk(cls, root_directory: str): current_pmapper_version = packaging.version.parse(principalmapper.__version__) loaded_graph_version = packaging.version.parse(metadata['pmapper_version']) - if current_pmapper_version.release[0] != loaded_graph_version.release[0] or \ - current_pmapper_version.release[1] != loaded_graph_version.release[1]: - raise ValueError('Loaded Graph data was from a different version of Principal Mapper ({}), but the current ' - 'version of Principal Mapper ({}) may not support it. Either update the stored Graph data ' - 'and its metadata, or regraph the account.'.format(loaded_graph_version, - current_pmapper_version)) + if current_pmapper_version.major != loaded_graph_version.major or current_pmapper_version.minor != loaded_graph_version.minor: + raise ValueError( + f'The loaded Graph data came from a different version of Principal Mapper ' + f'({str(loaded_graph_version)}) that is not compatible with this version of Principal Mapper ' + f'({str(current_pmapper_version)}). You will need to recreate the organization (`pmapper orgs ' + f'create`).' + ) + + with open(regulardatafilepath) as f: + acctdata = json.load(f) # type: dict policies = [] with open(policiesfilepath) as f: @@ -216,4 +229,12 @@ def create_graph_from_local_disk(cls, root_directory: str): edges.append(Edge(source=source, destination=destination, reason=edge['reason'], short_reason=edge['short_reason'])) - return Graph(nodes=nodes, edges=edges, policies=policies, groups=groups, metadata=metadata) + return Graph( + nodes=nodes, + edges=edges, + policies=policies, + groups=groups, + account=acctdata.get('account'), + partition=acctdata.get('partition'), + metadata=metadata + ) diff --git a/principalmapper/common/org_trees.py b/principalmapper/common/org_trees.py index cff72b8..e023a91 100644 --- a/principalmapper/common/org_trees.py +++ b/principalmapper/common/org_trees.py @@ -19,6 +19,10 @@ import os.path from typing import List, Optional, Tuple +import packaging +import packaging.version + +import principalmapper from principalmapper.common import Edge from principalmapper.common.policies import Policy @@ -86,10 +90,13 @@ def as_dictionary(self) -> dict: class OrganizationTree(object): """The OrganizationGraph object represents an AWS Organization, which is a collection of AWS accounts. These accounts are organized in a hierarchy (we use a tree for this). + + * **v1.2.0:** Added the required 'partition' field """ def __init__(self, org_id: str, management_account_id: str, root_ous: List[OrganizationNode], - all_scps: List[Policy], accounts: List[str], edge_list: List[Edge], metadata: dict): + all_scps: List[Policy], accounts: List[str], edge_list: List[Edge], metadata: dict, + partition: str): self.org_id = org_id self.management_account_id = management_account_id self.root_ous = root_ous @@ -99,6 +106,7 @@ def __init__(self, org_id: str, management_account_id: str, root_ous: List[Organ if 'pmapper_version' not in metadata: raise ValueError('The pmapper_version key/value (str) is required: {"pmapper_version": "..."}') self.metadata = metadata + self.partition = partition def as_dictionary(self) -> dict: """Returns a dictionary representation of this OrganizationTree object. Used for serialization to disk. We @@ -109,7 +117,8 @@ def as_dictionary(self) -> dict: 'management_account_id': self.management_account_id, 'root_ous': [x.as_dictionary() for x in self.root_ous], 'edge_list': [x.to_dictionary() for x in self.edge_list], - 'accounts': self.accounts + 'accounts': self.accounts, + 'partition': self.partition } def save_organization_to_disk(self, dirpath: str): @@ -163,6 +172,17 @@ def create_from_dir(cls, dirpath: str): with open(metadata_filepath) as fd: metadata_obj = json.load(fd) + # verify pmapper_version + current_pmapper_version = packaging.version.parse(principalmapper.__version__) + loaded_orgtree_version = packaging.version.parse(metadata_obj['pmapper_version']) + if current_pmapper_version.major != loaded_orgtree_version.major or current_pmapper_version.minor != loaded_orgtree_version.minor: + raise ValueError( + f'The loaded organization data came from a different version of Principal Mapper ' + f'({str(loaded_orgtree_version)}) that is not compatible with this version of Principal Mapper ' + f'({str(current_pmapper_version)}). You will need to recreate the organization (`pmapper orgs ' + f'create`).' + ) + # load the OrganizationX objects org_datafile_path = os.path.join(dirpath, 'org_data.json') with open(org_datafile_path) as fd: @@ -188,5 +208,6 @@ def _produce_ou(ou_dict: dict) -> OrganizationNode: [x for x in policies.values()], org_dictrepr['accounts'], org_dictrepr['edge_list'], - metadata_obj + metadata_obj, + org_dictrepr['partition'] ) diff --git a/principalmapper/graphing/autoscaling_edges.py b/principalmapper/graphing/autoscaling_edges.py index 78e5b2e..d2d2adc 100644 --- a/principalmapper/graphing/autoscaling_edges.py +++ b/principalmapper/graphing/autoscaling_edges.py @@ -35,7 +35,7 @@ class AutoScalingEdgeChecker(EdgeChecker): def return_edges(self, nodes: List[Node], region_allow_list: Optional[List[str]] = None, region_deny_list: Optional[List[str]] = None, scps: Optional[List[List[dict]]] = None, - client_args_map: Optional[dict] = None) -> List[Edge]: + client_args_map: Optional[dict] = None, partition: str = 'aws') -> List[Edge]: """Fulfills expected method return_edges.""" logger.info('Generating Edges based on EC2 Auto Scaling.') @@ -48,7 +48,7 @@ def return_edges(self, nodes: List[Node], region_allow_list: Optional[List[str]] # Gather projects information for each region autoscaling_clients = [] if self.session is not None: - as_regions = botocore_tools.get_regions_to_search(self.session, 'autoscaling', region_allow_list, region_deny_list) + as_regions = botocore_tools.get_regions_to_search(self.session, 'autoscaling', region_allow_list, region_deny_list, partition) for region in as_regions: autoscaling_clients.append(self.session.create_client('autoscaling', region_name=region, **asargs)) @@ -67,14 +67,16 @@ def return_edges(self, nodes: List[Node], region_allow_list: Optional[List[str]] }) except ClientError as ex: - logger.warning('Unable to search region {} for launch configs. The region may be disabled, or the error may ' - 'be caused by an authorization issue. Continuing.'.format(as_client.meta.region_name)) - logger.debug('Exception details: {}'.format(ex)) + logger.warning( + f'Unable to search region {as_client.meta.region_name} for launch configs. The region may be ' + f'disabled, or the error may be caused by an authorization issue. Continuing.' + ) + logger.debug(f'Exception details: {ex}') result = generate_edges_locally(nodes, scps, launch_configs) for edge in result: - logger.info("Found new edge: {}".format(edge.describe_edge())) + logger.info(f"Found new edge: {edge.describe_edge()}") return result diff --git a/principalmapper/graphing/cloudformation_edges.py b/principalmapper/graphing/cloudformation_edges.py index b2f867f..7ccf01f 100644 --- a/principalmapper/graphing/cloudformation_edges.py +++ b/principalmapper/graphing/cloudformation_edges.py @@ -37,7 +37,7 @@ class CloudFormationEdgeChecker(EdgeChecker): def return_edges(self, nodes: List[Node], region_allow_list: Optional[List[str]] = None, region_deny_list: Optional[List[str]] = None, scps: Optional[List[List[dict]]] = None, - client_args_map: Optional[dict] = None) -> List[Edge]: + client_args_map: Optional[dict] = None, partition: str = 'aws') -> List[Edge]: """Fulfills expected method return_edges.""" logger.info('Pulling data on CloudFormation stacks.') @@ -50,7 +50,7 @@ def return_edges(self, nodes: List[Node], region_allow_list: Optional[List[str]] # Grab existing stacks in each region cloudformation_clients = [] if self.session is not None: - cf_regions = botocore_tools.get_regions_to_search(self.session, 'cloudformation', region_allow_list, region_deny_list) + cf_regions = botocore_tools.get_regions_to_search(self.session, 'cloudformation', region_allow_list, region_deny_list, partition) for region in cf_regions: cloudformation_clients.append(self.session.create_client('cloudformation', region_name=region, **cfargs)) diff --git a/principalmapper/graphing/codebuild_edges.py b/principalmapper/graphing/codebuild_edges.py index a8b8cd2..ffa14cb 100644 --- a/principalmapper/graphing/codebuild_edges.py +++ b/principalmapper/graphing/codebuild_edges.py @@ -35,7 +35,7 @@ class CodeBuildEdgeChecker(EdgeChecker): def return_edges(self, nodes: List[Node], region_allow_list: Optional[List[str]] = None, region_deny_list: Optional[List[str]] = None, scps: Optional[List[List[dict]]] = None, - client_args_map: Optional[dict] = None) -> List[Edge]: + client_args_map: Optional[dict] = None, partition: str = 'aws') -> List[Edge]: """Fulfills expected method return_edges.""" logger.info('Generating Edges based on CodeBuild.') @@ -49,7 +49,7 @@ def return_edges(self, nodes: List[Node], region_allow_list: Optional[List[str]] codebuild_clients = [] if self.session is not None: - cf_regions = botocore_tools.get_regions_to_search(self.session, 'codebuild', region_allow_list, region_deny_list) + cf_regions = botocore_tools.get_regions_to_search(self.session, 'codebuild', region_allow_list, region_deny_list, partition) for region in cf_regions: codebuild_clients.append(self.session.create_client('codebuild', region_name=region, **cbargs)) diff --git a/principalmapper/graphing/cross_account_edges.py b/principalmapper/graphing/cross_account_edges.py index 511502e..cdcffc1 100644 --- a/principalmapper/graphing/cross_account_edges.py +++ b/principalmapper/graphing/cross_account_edges.py @@ -45,7 +45,7 @@ def _check_assume_role(ga, na, gb, nb, scps) -> bool: conditions['aws:username'] = na.searchable_name().split('/')[1] conditions['aws:SecureTransport'] = 'true' - conditions['aws:PrincipalAccount'] = ga.metadata['account_id'] + conditions['aws:PrincipalAccount'] = ga.account conditions['aws:PrincipalArn'] = na.arn if 'org-id' in ga.metadata: conditions['aws:PrincipalOrgID'] = ga.metadata['org-id'] diff --git a/principalmapper/graphing/ec2_edges.py b/principalmapper/graphing/ec2_edges.py index 82f0325..fb20143 100644 --- a/principalmapper/graphing/ec2_edges.py +++ b/principalmapper/graphing/ec2_edges.py @@ -36,7 +36,7 @@ class EC2EdgeChecker(EdgeChecker): def return_edges(self, nodes: List[Node], region_allow_list: Optional[List[str]] = None, region_deny_list: Optional[List[str]] = None, scps: Optional[List[List[dict]]] = None, - client_args_map: Optional[dict] = None) -> List[Edge]: + client_args_map: Optional[dict] = None, partition: str = 'aws') -> List[Edge]: """Fulfills expected method return_edges.""" logger.info('Generating Edges based on EC2.') diff --git a/principalmapper/graphing/edge_checker.py b/principalmapper/graphing/edge_checker.py index 5930e43..a69ccef 100644 --- a/principalmapper/graphing/edge_checker.py +++ b/principalmapper/graphing/edge_checker.py @@ -33,9 +33,9 @@ def __init__(self, session: botocore.session.Session): def return_edges(self, nodes: List[Node], region_allow_list: Optional[List[str]] = None, region_deny_list: Optional[List[str]] = None, scps: Optional[List[List[dict]]] = None, - client_args_map: Optional[dict] = None) -> List[Edge]: - """Subclasses shall override this method. Given a list of nodes, the EdgeChecker should be able to use its session - object in order to make clients and call the AWS API to resolve information about the account. Then, + client_args_map: Optional[dict] = None, partition: str = 'aws') -> List[Edge]: + """Subclasses shall override this method. Given a list of nodes, the EdgeChecker should be able to use its + session object in order to make clients and call the AWS API to resolve information about the account. Then, with this information, it should return a list of edges between the passed nodes. The region allow/deny lists are mutually-exclusive (i.e. at least one of which has the value None) lists of diff --git a/principalmapper/graphing/edge_identification.py b/principalmapper/graphing/edge_identification.py index 251d45e..0348814 100644 --- a/principalmapper/graphing/edge_identification.py +++ b/principalmapper/graphing/edge_identification.py @@ -51,14 +51,17 @@ def obtain_edges(session: Optional[botocore.session.Session], checker_list: List[str], nodes: List[Node], region_allow_list: Optional[List[str]] = None, region_deny_list: Optional[List[str]] = None, - scps: Optional[List[List[dict]]] = None, client_args_map: Optional[dict] = None) -> List[Edge]: + scps: Optional[List[List[dict]]] = None, client_args_map: Optional[dict] = None, + partition: str = 'aws') -> List[Edge]: """Given a list of nodes and a botocore Session, return a list of edges between those nodes. Only checks against services passed in the checker_list param. """ result = [] logger.info('Initiating edge checks.') - logger.debug('Services being checked for edges: {}'.format(checker_list)) + logger.debug(f'Services being checked for edges: {checker_list}') for check in checker_list: if check in checker_map: checker_obj = checker_map[check](session) - result.extend(checker_obj.return_edges(nodes, region_allow_list, region_deny_list, scps, client_args_map)) + result.extend( + checker_obj.return_edges(nodes, region_allow_list, region_deny_list, scps, client_args_map, partition) + ) return result diff --git a/principalmapper/graphing/gathering.py b/principalmapper/graphing/gathering.py index 9e64d95..7bd56c8 100644 --- a/principalmapper/graphing/gathering.py +++ b/principalmapper/graphing/gathering.py @@ -63,9 +63,10 @@ def create_graph(session: botocore.session.Session, service_list: list, region_a stsclient = session.create_client('sts', **stsargs) logger.debug(stsclient.meta.endpoint_url) caller_identity = stsclient.get_caller_identity() - logger.debug("Caller Identity: {}".format(caller_identity['Arn'])) + logger.debug(f"Caller Identity: {caller_identity['Arn']}") + current_partition = arns.get_partition(caller_identity['Arn']) + current_account = caller_identity['Account'] metadata = { - 'account_id': caller_identity['Account'], 'pmapper_version': principalmapper.__version__ } @@ -93,15 +94,15 @@ def create_graph(session: botocore.session.Session, service_list: list, region_a # Pull S3, SNS, SQS, KMS, and Secrets Manager resource policies try: - policies_result.extend(get_s3_bucket_policies(session, client_args_map)) - policies_result.extend(get_sns_topic_policies(session, region_allow_list, region_deny_list, client_args_map)) - policies_result.extend(get_sqs_queue_policies(session, caller_identity['Account'], region_allow_list, region_deny_list, client_args_map)) - policies_result.extend(get_kms_key_policies(session, region_allow_list, region_deny_list, client_args_map)) - policies_result.extend(get_secrets_manager_policies(session, region_allow_list, region_deny_list, client_args_map)) + policies_result.extend(get_s3_bucket_policies(session, client_args_map, current_partition)) + policies_result.extend(get_sns_topic_policies(session, region_allow_list, region_deny_list, client_args_map, current_partition)) + policies_result.extend(get_sqs_queue_policies(session, caller_identity['Account'], region_allow_list, region_deny_list, client_args_map, current_partition)) + policies_result.extend(get_kms_key_policies(session, region_allow_list, region_deny_list, client_args_map, current_partition)) + policies_result.extend(get_secrets_manager_policies(session, region_allow_list, region_deny_list, client_args_map, current_partition)) except: pass - return Graph(nodes_result, edges_result, policies_result, groups_result, metadata) + return Graph(nodes_result, edges_result, policies_result, groups_result, current_account, current_partition, metadata) def get_nodes_groups_and_policies(iamclient) -> dict: @@ -282,7 +283,8 @@ def get_nodes_groups_and_policies(iamclient) -> dict: return result -def get_s3_bucket_policies(session: botocore.session.Session, client_args_map: Optional[dict] = None) -> List[Policy]: +def get_s3_bucket_policies(session: botocore.session.Session, client_args_map: Optional[dict] = None, + partition: str = 'aws') -> List[Policy]: """Using a botocore Session object, return a list of Policy objects representing the bucket policies of each S3 bucket in this account. """ @@ -291,7 +293,7 @@ def get_s3_bucket_policies(session: botocore.session.Session, client_args_map: O s3client = session.create_client('s3', **s3args) buckets = [x['Name'] for x in s3client.list_buckets()['Buckets']] for bucket in buckets: - bucket_arn = 'arn:aws:s3:::{}'.format(bucket) # TODO: allow different partition + bucket_arn = f'arn:aws:{partition}:::{bucket}' try: bucket_policy = json.loads(s3client.get_bucket_policy(Bucket=bucket)['Policy']) result.append(Policy( @@ -299,12 +301,10 @@ def get_s3_bucket_policies(session: botocore.session.Session, client_args_map: O bucket, bucket_policy )) - logger.info('Caching policy for {}'.format(bucket_arn)) + logger.info(f'Caching policy for {bucket_arn}') except botocore.exceptions.ClientError as ex: if 'NoSuchBucketPolicy' in str(ex): - logger.info('Bucket {} does not have a bucket policy, adding a "stub" policy instead.'.format( - bucket - )) + logger.info(f'Bucket {bucket} does not have a bucket policy, adding a "stub" policy instead.') result.append(Policy( bucket_arn, bucket, @@ -314,14 +314,15 @@ def get_s3_bucket_policies(session: botocore.session.Session, client_args_map: O } )) else: - logger.info('Unable to retrieve bucket policy for {}. You should add this manually. Continuing.'.format(bucket)) - logger.debug('Exception was: {}'.format(ex)) + logger.info(f'Unable to retrieve bucket policy for {bucket}. You should add this manually. Continuing.') + logger.debug(f'Exception was: {ex}') return result def get_kms_key_policies(session: botocore.session.Session, region_allow_list: Optional[List[str]] = None, - region_deny_list: Optional[List[str]] = None, client_args_map: Optional[dict] = None) -> List[Policy]: + region_deny_list: Optional[List[str]] = None, client_args_map: Optional[dict] = None, + partition: str = 'aws') -> List[Policy]: """Using a botocore Session object, return a list of Policy objects representing the key policies of each KMS key in this account. @@ -333,7 +334,7 @@ def get_kms_key_policies(session: botocore.session.Session, region_allow_list: O kmsargs = client_args_map.get('kms', {}) # Iterate through all regions of KMS where possible - for kms_region in get_regions_to_search(session, 'kms', region_allow_list, region_deny_list): + for kms_region in get_regions_to_search(session, 'kms', region_allow_list, region_deny_list, partition): try: # Grab the keys cmks = [] @@ -352,15 +353,19 @@ def get_kms_key_policies(session: botocore.session.Session, region_allow_list: O )) logger.info('Caching policy for {}'.format(cmk)) except botocore.exceptions.ClientError as ex: - logger.info('Unable to search KMS in region {} for key policies. The region may be disabled, or the current principal may not be authorized to access the service. Continuing.'.format(kms_region)) - logger.debug('Exception was: {}'.format(ex)) + logger.info( + f'Unable to search KMS in region {kms_region} for key policies. The region may be disabled, or the ' + f'current principal may not be authorized to access the service. Continuing.' + ) + logger.debug(f'Exception was: {ex}') continue return result def get_sns_topic_policies(session: botocore.session.Session, region_allow_list: Optional[List[str]] = None, - region_deny_list: Optional[List[str]] = None, client_args_map: Optional[dict] = None) -> List[Policy]: + region_deny_list: Optional[List[str]] = None, client_args_map: Optional[dict] = None, + partition: str = 'aws') -> List[Policy]: """Using a botocore Session object, return a list of Policy objects representing the topic policies of each SNS topic in this account. @@ -372,7 +377,7 @@ def get_sns_topic_policies(session: botocore.session.Session, region_allow_list: snsargs = client_args_map.get('sns', {}) # Iterate through all regions of SNS where possible - for sns_region in get_regions_to_search(session, 'sns', region_allow_list, region_deny_list): + for sns_region in get_regions_to_search(session, 'sns', region_allow_list, region_deny_list, partition): try: # Grab the topics topics = [] @@ -391,8 +396,11 @@ def get_sns_topic_policies(session: botocore.session.Session, region_allow_list: )) logger.info('Caching policy for {}'.format(topic)) except botocore.exceptions.ClientError as ex: - logger.info('Unable to search SNS in region {} for topic policies. The region may be disabled, or the current principal may not be authorized to access the service. Continuing.'.format(sns_region)) - logger.debug('Exception was: {}'.format(ex)) + logger.info( + f'Unable to search SNS in region {sns_region} for topic policies. The region may be disabled, or ' + f'the current principal may not be authorized to access the service. Continuing.' + ) + logger.debug(f'Exception was: {ex}') continue return result @@ -400,7 +408,7 @@ def get_sns_topic_policies(session: botocore.session.Session, region_allow_list: def get_sqs_queue_policies(session: botocore.session.Session, account_id: str, region_allow_list: Optional[List[str]] = None, region_deny_list: Optional[List[str]] = None, - client_args_map: Optional[dict] = None) -> List[Policy]: + client_args_map: Optional[dict] = None, partition: str = 'aws') -> List[Policy]: """Using a botocore Session object, return a list of Policy objects representing the queue policies of each SQS queue in this account. @@ -412,7 +420,7 @@ def get_sqs_queue_policies(session: botocore.session.Session, account_id: str, sqsargs = client_args_map.get('sqs', {}) # Iterate through all regions of SQS where possible - for sqs_region in get_regions_to_search(session, 'sqs', region_allow_list, region_deny_list): + for sqs_region in get_regions_to_search(session, 'sqs', region_allow_list, region_deny_list, partition): try: # Grab the queue names queue_urls = [] @@ -430,30 +438,34 @@ def get_sqs_queue_policies(session: botocore.session.Session, account_id: str, if 'Policy' in sqs_policy_response: sqs_policy_doc = json.loads(sqs_policy_response['Policy']) result.append(Policy( - 'arn:aws:sqs:{}:{}:{}'.format(sqs_region, account_id, queue_name), + f'arn:{partition}:sqs:{sqs_region}:{account_id}:{queue_name}', queue_name, json.loads(sqs_policy_doc) )) - logger.info('Caching policy for {}'.format('arn:aws:sqs:{}:{}:{}'.format(sqs_region, account_id, queue_name))) + logger.info(f'Caching policy for {f"arn:{partition}:sqs:{sqs_region}:{account_id}:{queue_name}"}') else: result.append(Policy( - 'arn:aws:sqs:{}:{}:{}'.format(sqs_region, account_id, queue_name), + f'arn:{partition}:sqs:{sqs_region}:{account_id}:{queue_name}', queue_name, { "Statement": [], "Version": "2012-10-17" } )) - logger.info('Queue {} does not have a queue policy, adding a "stub" policy instead.'.format(queue_name)) + logger.info(f'Queue {queue_name} does not have a queue policy, adding a "stub" policy instead.') except botocore.exceptions.ClientError as ex: - logger.info('Unable to search SQS in region {} for queues. The region may be disabled, or the current principal may not be authorized to access the service. Continuing.'.format(sqs_region)) - logger.debug('Exception was: {}'.format(ex)) + logger.info( + f'Unable to search SQS in region {sqs_region} for queues. The region may be disabled, or the current ' + f'principal may not be authorized to access the service. Continuing.' + ) + logger.debug(f'Exception was: {ex}') return result def get_secrets_manager_policies(session: botocore.session.Session, region_allow_list: Optional[List[str]] = None, - region_deny_list: Optional[List[str]] = None, client_args_map: Optional[dict] = None) -> List[Policy]: + region_deny_list: Optional[List[str]] = None, client_args_map: Optional[dict] = None, + partition: str = 'aws') -> List[Policy]: """Using a botocore Session object, return a list of Policy objects representing the resource policies of the secrets in AWS Secrets Manager. @@ -465,7 +477,7 @@ def get_secrets_manager_policies(session: botocore.session.Session, region_allow smargs = client_args_map.get('secretsmanager', {}) # Iterate through all regions of Secrets Manager where possible - for sm_region in get_regions_to_search(session, 'secretsmanager', region_allow_list, region_deny_list): + for sm_region in get_regions_to_search(session, 'secretsmanager', region_allow_list, region_deny_list, partition): try: # Grab the ARNs of the secrets in this region secret_arns = [] @@ -850,6 +862,7 @@ def get_organizations_data(session: botocore.session.Session) -> OrganizationTre # grab account data stsclient = session.create_client('sts') account_data = stsclient.get_caller_identity() + partition = arns.get_partition(account_data['Arn']) # try to grab org data, raising RuntimeError if appropriate try: @@ -857,9 +870,11 @@ def get_organizations_data(session: botocore.session.Session) -> OrganizationTre organization_data = orgsclient.describe_organization() except botocore.exceptions.ClientError as ex: if 'AccessDeniedException' in str(ex): - raise RuntimeError('Encountered a permission error. Either the current principal ({}) is not authorized to ' - 'interact with AWS Organizations, or the current account ({}) is not the ' - 'management account'.format(account_data['Arn'], account_data['Account'])) + raise RuntimeError( + f'Encountered a permission error. Either the current principal ({account_data["Arn"]}) is not ' + f'authorized to interact with AWS Organizations, or the current account ' + f'({account_data["Account"]}) is not the management account' + ) else: raise ex @@ -875,7 +890,8 @@ def get_organizations_data(session: botocore.session.Session) -> OrganizationTre None, # get SCPs later None, # get account list later [], # caller is responsible for creating and setting the edge list - {'pmapper_version': principalmapper.__version__} + {'pmapper_version': principalmapper.__version__}, + partition ) scp_list = [] @@ -904,7 +920,7 @@ def _get_scps_for_target(target_id: str) -> List[Policy]: desc_policy_resp = orgsclient.describe_policy(PolicyId=policy_arn.split('/')[-1]) scps_result.append(Policy(policy_arn, policy_name, json.loads(desc_policy_resp['Policy']['Content']))) - logger.debug('SCPs of {}: {}'.format(target_id, [x.arn for x in scps_result])) + logger.debug(f'SCPs of {target_id}: {[x.arn for x in scps_result]}') scp_list.extend(scps_result) return scps_result @@ -918,7 +934,7 @@ def _get_tags_for_target(target_id: str) -> dict: for tag in ltp_page['Tags']: target_tags[tag['Key']] = tag['Value'] - logger.debug('Tags for {}: {}'.format(target_id, target_tags)) + logger.debug(f'Tags for {target_id}: {target_tags}') return target_tags # for each root, recursively grab child OUs while filling out OrganizationNode/OrganizationAccount objects @@ -1002,4 +1018,4 @@ def _get_policy_by_arn_or_raise(arn: str, policies: List[Policy]) -> Policy: for policy in policies: if arn == policy.arn: return policy - raise ValueError('Could not locate policy {}.'.format(arn)) + raise ValueError(f'Could not locate policy {arn}.') diff --git a/principalmapper/graphing/graph_actions.py b/principalmapper/graphing/graph_actions.py index bb317e8..bc41835 100644 --- a/principalmapper/graphing/graph_actions.py +++ b/principalmapper/graphing/graph_actions.py @@ -23,6 +23,7 @@ import botocore.session from principalmapper.common import Graph from principalmapper.graphing import gathering +from principalmapper.util import arns from principalmapper.util.storage import get_default_graph_path from typing import List, Optional @@ -43,18 +44,21 @@ def print_graph_data(graph: Graph) -> None: """Given a Graph object, prints a small amount of information about the Graph. This fulfills `pmapper graph display`, and also gets ran after `pmapper graph --create`. """ - print('Graph Data for Account: {}'.format(graph.metadata['account_id'])) + if graph.partition == 'aws': + print(f'Graph Data for Account: {graph.account}') + else: + print(f'Graph Data for Account: {graph.partition}:{graph.account}') if 'org-id' in graph.metadata: - print(' Organization: {}'.format(graph.metadata['org-id'])) - print(' OU Path: {}'.format(graph.metadata['org-path'])) + print(f' Organization: {graph.metadata["org-id"]}') + print(f' OU Path: {graph.metadata["org-path"]}') admin_count = 0 for node in graph.nodes: if node.is_admin: admin_count += 1 - print(' # of Nodes: {} ({} admins)'.format(len(graph.nodes), admin_count)) - print(' # of Edges: {}'.format(len(graph.edges))) - print(' # of Groups: {}'.format(len(graph.groups))) - print(' # of (tracked) Policies: {}'.format(len(graph.policies))) + print(f' # of Nodes: {len(graph.nodes)} ({admin_count} admins)') + print(f' # of Edges: {len(graph.edges)}') + print(f' # of Groups: {len(graph.groups)}') + print(f' # of (tracked) Policies: {len(graph.policies)}') def get_graph_from_disk(location: str) -> Graph: @@ -76,8 +80,15 @@ def get_existing_graph(session: Optional[botocore.session.Session], account: Opt elif session is not None: stsclient = session.create_client('sts') response = stsclient.get_caller_identity() - logger.debug('Loading graph based on sts:GetCallerIdentity result: {}'.format(response['Account'])) - graph = get_graph_from_disk(os.path.join(get_default_graph_path(response['Account']))) + partition = arns.get_partition(response['Arn']) + if partition == 'aws': + logger.debug('Loading graph based on sts:GetCallerIdentity result: {}'.format(response['Account'])) + graph = get_graph_from_disk(os.path.join(get_default_graph_path(response['Account']))) + else: + logger.debug('Loading graph based on sts:GetCallerIdentity result: {}:{}'.format(partition, response['Account'])) + graph = get_graph_from_disk( + get_default_graph_path(f'{partition}:{response["Account"]}') + ) else: raise ValueError('One of the parameters `account` or `session` must not be None') return graph diff --git a/principalmapper/graphing/graph_cli.py b/principalmapper/graphing/graph_cli.py index f31342f..6088f18 100644 --- a/principalmapper/graphing/graph_cli.py +++ b/principalmapper/graphing/graph_cli.py @@ -179,7 +179,7 @@ def process_arguments(parsed_args: Namespace): graph = graph_actions.create_new_graph(session, service_list, parsed_args.include_regions, parsed_args.exclude_regions, scps, client_args_map) graph_actions.print_graph_data(graph) - graph.store_graph_as_json(os.path.join(get_storage_root(), graph.metadata['account_id'])) + graph.store_graph_as_json(os.path.join(get_storage_root(), graph.account)) elif parsed_args.picked_graph_cmd == 'display': if parsed_args.account is None: @@ -206,4 +206,14 @@ def process_arguments(parsed_args: Namespace): version = account_metadata['pmapper_version'] print("{} (PMapper Version {})".format(direct.name, version)) + partition_pattern = re.compile(r'aws.*') + for direct in storage_root.iterdir(): + if partition_pattern.search(str(direct)) is not None: + for subdirect in direct.iterdir(): + if account_id_pattern.search(str(subdirect)) is not None: + metadata_file = subdirect.joinpath(Path('metadata.json')) + with open(str(metadata_file)) as fd: + version = json.load(fd)['pmapper_version'] + print(f'{direct.name}:{subdirect.name} (PMapper Version {version})') + return 0 diff --git a/principalmapper/graphing/iam_edges.py b/principalmapper/graphing/iam_edges.py index 6caa3a7..7ccd774 100644 --- a/principalmapper/graphing/iam_edges.py +++ b/principalmapper/graphing/iam_edges.py @@ -34,7 +34,7 @@ class IAMEdgeChecker(EdgeChecker): def return_edges(self, nodes: List[Node], region_allow_list: Optional[List[str]] = None, region_deny_list: Optional[List[str]] = None, scps: Optional[List[List[dict]]] = None, - client_args_map: Optional[dict] = None) -> List[Edge]: + client_args_map: Optional[dict] = None, partition: str = 'aws') -> List[Edge]: """Fulfills expected method return_edges.""" logger.info('Generating Edges based on IAM') diff --git a/principalmapper/graphing/lambda_edges.py b/principalmapper/graphing/lambda_edges.py index 7f6c830..c4531b7 100644 --- a/principalmapper/graphing/lambda_edges.py +++ b/principalmapper/graphing/lambda_edges.py @@ -37,7 +37,7 @@ class LambdaEdgeChecker(EdgeChecker): def return_edges(self, nodes: List[Node], region_allow_list: Optional[List[str]] = None, region_deny_list: Optional[List[str]] = None, scps: Optional[List[List[dict]]] = None, - client_args_map: Optional[dict] = None) -> List[Edge]: + client_args_map: Optional[dict] = None, partition: str = 'aws') -> List[Edge]: """Fulfills expected method return_edges. If session object is None, runs checks in offline mode.""" logger.info('Pulling data on Lambda functions') @@ -49,7 +49,7 @@ def return_edges(self, nodes: List[Node], region_allow_list: Optional[List[str]] lambda_clients = [] if self.session is not None: - lambda_regions = botocore_tools.get_regions_to_search(self.session, 'lambda', region_allow_list, region_deny_list) + lambda_regions = botocore_tools.get_regions_to_search(self.session, 'lambda', region_allow_list, region_deny_list, partition) for region in lambda_regions: lambda_clients.append(self.session.create_client('lambda', region_name=region, **lambdaargs)) diff --git a/principalmapper/graphing/orgs_cli.py b/principalmapper/graphing/orgs_cli.py index 32ad3f5..34c33d9 100644 --- a/principalmapper/graphing/orgs_cli.py +++ b/principalmapper/graphing/orgs_cli.py @@ -209,13 +209,23 @@ def _walk_and_print_ou(org_node: OrganizationNode, indent_level: int, inherited_ print("Organization IDs:") print("---") storage_root = Path(get_storage_root()) - account_id_pattern = re.compile(r'o-\w+') + org_id_pattern = re.compile(r'o-\w+') for direct in storage_root.iterdir(): - if account_id_pattern.search(str(direct)) is not None: + if org_id_pattern.search(str(direct)) is not None: metadata_file = direct.joinpath(Path('metadata.json')) with open(str(metadata_file)) as fd: version = json.load(fd)['pmapper_version'] - print("{} (PMapper Version {})".format(direct.name, version)) + print(f"{direct.name} (PMapper Version {version})") + + partition_pattern = re.compile(r'aws.*') + for direct in storage_root.iterdir(): + if partition_pattern.search(str(direct)) is not None: + for subdirect in direct.iterdir(): + if org_id_pattern.search(str(subdirect)) is not None: + metadata_file = subdirect.joinpath(Path('metadata.json')) + with open(str(metadata_file)) as fd: + version = json.load(fd)['pmapper_version'] + print(f'{direct.name}:{subdirect.name} (PMapper Version {version})') return 0 diff --git a/principalmapper/graphing/sagemaker_edges.py b/principalmapper/graphing/sagemaker_edges.py index 30f929f..7cf70a0 100644 --- a/principalmapper/graphing/sagemaker_edges.py +++ b/principalmapper/graphing/sagemaker_edges.py @@ -36,7 +36,7 @@ class SageMakerEdgeChecker(EdgeChecker): def return_edges(self, nodes: List[Node], region_allow_list: Optional[List[str]] = None, region_deny_list: Optional[List[str]] = None, scps: Optional[List[List[dict]]] = None, - client_args_map: Optional[dict] = None) -> List[Edge]: + client_args_map: Optional[dict] = None, partition: str = 'aws') -> List[Edge]: """fulfills expected method""" logger.info('Generating Edges based on SageMaker') diff --git a/principalmapper/graphing/ssm_edges.py b/principalmapper/graphing/ssm_edges.py index 0b59d89..c8e5b7f 100644 --- a/principalmapper/graphing/ssm_edges.py +++ b/principalmapper/graphing/ssm_edges.py @@ -35,7 +35,7 @@ class SSMEdgeChecker(EdgeChecker): def return_edges(self, nodes: List[Node], region_allow_list: Optional[List[str]] = None, region_deny_list: Optional[List[str]] = None, scps: Optional[List[List[dict]]] = None, - client_args_map: Optional[dict] = None) -> List[Edge]: + client_args_map: Optional[dict] = None, partition: str = 'aws') -> List[Edge]: """Fulfills expected method return_edges. If session object is None, runs checks in offline mode.""" logger.info('Generating Edges based on SSM') diff --git a/principalmapper/graphing/sts_edges.py b/principalmapper/graphing/sts_edges.py index 34d3bc9..ce31632 100644 --- a/principalmapper/graphing/sts_edges.py +++ b/principalmapper/graphing/sts_edges.py @@ -35,7 +35,7 @@ class STSEdgeChecker(EdgeChecker): def return_edges(self, nodes: List[Node], region_allow_list: Optional[List[str]] = None, region_deny_list: Optional[List[str]] = None, scps: Optional[List[List[dict]]] = None, - client_args_map: Optional[dict] = None) -> List[Edge]: + client_args_map: Optional[dict] = None, partition: str = 'aws') -> List[Edge]: """Fulfills expected method return_edges. If the session object is None, performs checks in offline-mode""" result = generate_edges_locally(nodes, scps) diff --git a/principalmapper/querying/argquery_cli.py b/principalmapper/querying/argquery_cli.py index d073db4..e82518a 100644 --- a/principalmapper/querying/argquery_cli.py +++ b/principalmapper/querying/argquery_cli.py @@ -103,7 +103,7 @@ def process_arguments(parsed_args: Namespace): else: session = None graph = graph_actions.get_existing_graph(session, parsed_args.account) - logger.debug('Querying against graph {}'.format(graph.metadata['account_id'])) + logger.debug('Querying against graph {}'.format(graph.account)) # process condition args to generate input dict conditions = {} diff --git a/principalmapper/querying/presets/endgame.py b/principalmapper/querying/presets/endgame.py index 02840f6..72c1117 100644 --- a/principalmapper/querying/presets/endgame.py +++ b/principalmapper/querying/presets/endgame.py @@ -24,23 +24,23 @@ _service_resource_exposure_map = { 's3': { - 'pattern': re.compile(r"^arn:aws:s3:::[^/]+$"), + 'pattern': re.compile(r"^arn:\S+:s3:::[^/]+$"), 'actions': ['s3:PutBucketPolicy'] }, 'sns': { - 'pattern': re.compile(r"^arn:aws:sns:[a-z0-9-]+:[0-9]+:.*"), + 'pattern': re.compile(r"^arn:\S+:sns:[a-z0-9-]+:[0-9]+:.*"), 'actions': ['sns:AddPermission', 'sns:SetTopicAttributes'] }, 'sqs': { - 'pattern': re.compile(r"^arn:aws:sqs:[a-z0-9-]+:[0-9]+:.*"), + 'pattern': re.compile(r"^arn:\S+:sqs:[a-z0-9-]+:[0-9]+:.*"), 'actions': ['sqs:AddPermission', 'sqs:SetQueueAttributes'] }, 'kms': { - 'pattern': re.compile(r"^arn:aws:kms:[a-z0-9-]+:[0-9]+:key/.*"), + 'pattern': re.compile(r"^arn:\S+:kms:[a-z0-9-]+:[0-9]+:key/.*"), 'actions': ['kms:PutKeyPolicy'] }, 'secretsmanager': { - 'pattern': re.compile(r"^arn:aws:secretsmanager:[a-z0-9-]+:[0-9]+:.*"), + 'pattern': re.compile(r"^arn:\S+:secretsmanager:[a-z0-9-]+:[0-9]+:.*"), 'actions': ['secretsmanager:PutResourcePolicy'] } } @@ -87,7 +87,7 @@ def compose_endgame_map(graph: Graph, service_to_include: str = '*', skip_admins continue query_result = query_interface.local_check_authorization_full( - node, action, policy.arn, node.cache['conditions'], policy.policy_doc, graph.metadata['account_id'], + node, action, policy.arn, node.cache['conditions'], policy.policy_doc, graph.account, None, None ) @@ -102,7 +102,7 @@ def compose_endgame_map(graph: Graph, service_to_include: str = '*', skip_admins }) query_result = query_interface.local_check_authorization_full( node, action, policy.arn, conditions_copy, policy.policy_doc, - graph.metadata['account_id'], + graph.account, None, None ) if query_result: diff --git a/principalmapper/querying/presets/wrongadmin.py b/principalmapper/querying/presets/wrongadmin.py index 3bb3edf..6c23e1c 100644 --- a/principalmapper/querying/presets/wrongadmin.py +++ b/principalmapper/querying/presets/wrongadmin.py @@ -87,6 +87,7 @@ def _get_admin_reason(node: Node) -> List[str]: result = [] logger.debug("Checking if {} is an admin".format(node.searchable_name())) node_type = arns.get_resource(node.arn).split('/')[0] + partition = arns.get_partition(node.arn) # check if node can modify its own inline policies if node_type == 'user': @@ -101,7 +102,7 @@ def _get_admin_reason(node: Node) -> List[str]: action = 'iam:AttachUserPolicy' else: action = 'iam:AttachRolePolicy' - condition_keys = {'iam:PolicyARN': 'arn:aws:iam::aws:policy/AdministratorAccess'} + condition_keys = {'iam:PolicyARN': f'arn:{partition}:iam::aws:policy/AdministratorAccess'} if query_interface.local_check_authorization_handling_mfa(node, action, node.arn, condition_keys)[0]: result.append('Can call {} to attach the AdministratorAccess policy to itself'.format(action)) diff --git a/principalmapper/querying/query_cli.py b/principalmapper/querying/query_cli.py index ab3e057..91e747a 100644 --- a/principalmapper/querying/query_cli.py +++ b/principalmapper/querying/query_cli.py @@ -85,7 +85,7 @@ def process_arguments(parsed_args: Namespace): session = None graph = graph_actions.get_existing_graph(session, parsed_args.account) - logger.debug('Querying against graph {}'.format(graph.metadata['account_id'])) + logger.debug('Querying against graph {}'.format(graph.account)) if parsed_args.with_resource_policy: resource_policy = query_utils.pull_cached_resource_policy_by_arn( diff --git a/principalmapper/querying/query_interface.py b/principalmapper/querying/query_interface.py index 0de95c9..fd5366e 100644 --- a/principalmapper/querying/query_interface.py +++ b/principalmapper/querying/query_interface.py @@ -113,7 +113,7 @@ def search_authorization_across_accounts(graph_scp_pairs: List[Tuple[Graph, Opti account_id_graph_scp_pair_map = {} for graph_scp_pair in graph_scp_pairs: - account_id_graph_scp_pair_map[graph_scp_pair[0].metadata['account_id']] = graph_scp_pair + account_id_graph_scp_pair_map[graph_scp_pair[0].account] = graph_scp_pair source_graph_scp_pair = account_id_graph_scp_pair_map[arns.get_account_id(principal.arn)] if local_check_authorization_full(principal, action_to_check, resource_to_check, condition_keys_to_check, diff --git a/principalmapper/querying/query_orgs.py b/principalmapper/querying/query_orgs.py index 7b62bf2..abf4e15 100644 --- a/principalmapper/querying/query_orgs.py +++ b/principalmapper/querying/query_orgs.py @@ -90,10 +90,9 @@ def produce_scp_list(graph: Graph, org: OrganizationTree) -> Optional[List[List[ want in that case.""" if 'org-id' not in graph.metadata or 'org-path' not in graph.metadata: - raise ValueError('Given graph for account {} does not have AWS Organizations data (try running ' - '`pmapper orgs create/update`).') + raise ValueError(f'Given Graph for account {graph.account} does not have AWS Organizations data') - if graph.metadata['account_id'] == org.management_account_id: + if graph.account == org.management_account_id: return None result = [] @@ -101,6 +100,6 @@ def produce_scp_list(graph: Graph, org: OrganizationTree) -> Optional[List[List[ # org-path is in the form '//[///]' so we split and start from [1] org_path_parts = graph.metadata['org-path'].split('/') - _grab_policies_and_traverse(org.root_ous, org_path_parts, 1, graph.metadata['account_id'], result) + _grab_policies_and_traverse(org.root_ous, org_path_parts, 1, graph.account, result) return result diff --git a/principalmapper/querying/query_utils.py b/principalmapper/querying/query_utils.py index e625566..36b336c 100644 --- a/principalmapper/querying/query_utils.py +++ b/principalmapper/querying/query_utils.py @@ -158,7 +158,7 @@ def pull_resource_policy_by_arn(session: botocore.session.Session, arn: Optional elif service == 's3': # arn:aws:s3:::/ client = session.create_client('s3') - bucket_name = arns.get_resource(arn).split('arn:aws:s3:::')[-1].split('/')[0] + bucket_name = arns.get_resource(arn).split(':s3:::')[-1].split('/')[0] logger.debug('Calling S3 API to retrieve bucket policy of {}'.format(bucket_name)) bucket_policy = json.loads(client.get_bucket_policy(Bucket=bucket_name)['Policy']) return bucket_policy @@ -199,7 +199,7 @@ def get_interaccount_search_list(all_graphs: List[Graph], inter_account_edges: L account_id_graph_map = {} for graph in all_graphs: - account_id_graph_map[graph.metadata['account_id']] = graph + account_id_graph_map[graph.account] = graph # Get initial list of edges first_set = get_edges_interaccount(account_id_graph_map[arns.get_account_id(node.arn)], inter_account_edges, node, nodes_found) diff --git a/principalmapper/util/botocore_tools.py b/principalmapper/util/botocore_tools.py index 4712e6e..a325891 100644 --- a/principalmapper/util/botocore_tools.py +++ b/principalmapper/util/botocore_tools.py @@ -46,7 +46,9 @@ def get_session(profile_arg: Optional[str], stsargs: Optional[dict] = None) -> b return result -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]: +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, + partition: str = 'aws') -> List[str]: """Using a botocore Session object, the name of a service, and either an allow-list or a deny-list (but not both), return a list of regions to be used during the gathering process. This uses the botocore Session object's get_available_regions method as the base list. @@ -58,12 +60,14 @@ def get_regions_to_search(session: botocore.session.Session, service_name: str, thrown if a region is specified inthe deny-list but not included in the base list. A ValueError is thrown if the allow-list AND deny-list are both not None. + + * **v1.2.0:** Added partition support (default to 'aws') """ if region_allow_list is not None and region_deny_list is not None: raise ValueError('This function allows only either the allow-list or the deny-list, but NOT both.') - base_list = session.get_available_regions(service_name) + base_list = session.get_available_regions(service_name, partition) result = [] @@ -78,6 +82,6 @@ def get_regions_to_search(session: botocore.session.Session, service_name: str, else: result = base_list - logger.debug('Final list of regions for {}: {}'.format(service_name, result)) + logger.debug(f'Final list of regions for {service_name}: {result}') return result diff --git a/principalmapper/util/storage.py b/principalmapper/util/storage.py index 695371e..ef82c70 100644 --- a/principalmapper/util/storage.py +++ b/principalmapper/util/storage.py @@ -56,5 +56,16 @@ def get_storage_root(): def get_default_graph_path(account_or_org: str): - """Returns a path to a given account or organization by the provided string.""" - return os.path.join(get_storage_root(), account_or_org) + """Returns a path to a given account or organization by the provided string. + + * **v1.2.0**: Added partition support, expected format is :. If there's no partition, + default is 'aws'. If partition is not 'aws', then we add that to the directories we hop through. + """ + if ':' in account_or_org: + argparts = account_or_org.split(':') + basedir = os.path.join(get_storage_root(), argparts[0]) + acctid = argparts[1] + else: + basedir = get_storage_root() + acctid = account_or_org + return os.path.join(basedir, acctid) diff --git a/principalmapper/visualizing/cli.py b/principalmapper/visualizing/cli.py index f7ae9f7..8bb0ec0 100644 --- a/principalmapper/visualizing/cli.py +++ b/principalmapper/visualizing/cli.py @@ -55,11 +55,11 @@ def process_arguments(parsed_args: Namespace): graph = graph_actions.get_existing_graph(session, parsed_args.account) if parsed_args.only_privesc: - filepath = './{}-privesc-risks.{}'.format(graph.metadata['account_id'], parsed_args.filetype) + filepath = './{}-privesc-risks.{}'.format(graph.account, parsed_args.filetype) graph_writer.draw_privesc_paths(graph, filepath, parsed_args.filetype) else: # create file - filepath = './{}.{}'.format(graph.metadata['account_id'], parsed_args.filetype) + filepath = './{}.{}'.format(graph.account, parsed_args.filetype) graph_writer.handle_request(graph, filepath, parsed_args.filetype, parsed_args.with_services) print('Created file {}'.format(filepath)) diff --git a/principalmapper/visualizing/graphviz_writer.py b/principalmapper/visualizing/graphviz_writer.py index c858da4..a270fe8 100644 --- a/principalmapper/visualizing/graphviz_writer.py +++ b/principalmapper/visualizing/graphviz_writer.py @@ -29,7 +29,7 @@ def write_standard_graphviz(graph: Graph, filepath: str, file_format: str, with_ # Load graph data into pydot pydg = pydot.Dot( graph_type='digraph', - graph_name='Principal Mapper Visualization: {}'.format(graph.metadata['account_id']), + graph_name='Principal Mapper Visualization: {}'.format(graph.account), overlap='scale', layout='neato', concentrate='true', diff --git a/requirements.txt b/requirements.txt index ce3865e..fba42ab 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,4 @@ botocore > 1.13 -packaging +packaging >= 21.0 python-dateutil pydot \ No newline at end of file diff --git a/setup.py b/setup.py index d810147..87d73c0 100644 --- a/setup.py +++ b/setup.py @@ -37,7 +37,7 @@ include_package_data=True, packages=find_packages(exclude=("tests", )), package_data={}, - python_requires='>=3.5, <4', # assume Python 4 will break + python_requires='>=3.6, <4', # assume Python 4 will break install_requires=['botocore', 'packaging', 'python-dateutil', 'pydot'], entry_points={ 'console_scripts': [ diff --git a/tests/build_test_graphs.py b/tests/build_test_graphs.py index 82c111b..ec6dbd9 100644 --- a/tests/build_test_graphs.py +++ b/tests/build_test_graphs.py @@ -237,7 +237,7 @@ def _get_s3_full_access_policy() -> dict: def _get_default_metadata() -> dict: """Constructs and returns a metadata dictionary to use across tests""" - return {'account_id': '000000000000', 'pmapper_version': principalmapper.__version__} + return {'account_id': '000000000000', 'pmapper_version': principalmapper.__version__, 'partition': 'aws'} def _make_trust_document(principal_element: dict) -> dict: From 5186f541c8150b6ba597b2672800794063211654 Mon Sep 17 00:00:00 2001 From: Erik Steringer Date: Fri, 28 Jan 2022 15:29:40 -0800 Subject: [PATCH 03/21] fix tests, added Glue edges --- .../graphing/edge_identification.py | 2 + principalmapper/graphing/glue_edges.py | 184 ++++++++++++++++++ tests/build_test_graphs.py | 6 +- tests/test_constructors.py | 8 +- 4 files changed, 193 insertions(+), 7 deletions(-) create mode 100644 principalmapper/graphing/glue_edges.py diff --git a/principalmapper/graphing/edge_identification.py b/principalmapper/graphing/edge_identification.py index 0348814..c69be18 100644 --- a/principalmapper/graphing/edge_identification.py +++ b/principalmapper/graphing/edge_identification.py @@ -25,6 +25,7 @@ from principalmapper.graphing.cloudformation_edges import CloudFormationEdgeChecker from principalmapper.graphing.codebuild_edges import CodeBuildEdgeChecker from principalmapper.graphing.ec2_edges import EC2EdgeChecker +from principalmapper.graphing.glue_edges import GlueEdgeChecker from principalmapper.graphing.iam_edges import IAMEdgeChecker from principalmapper.graphing.lambda_edges import LambdaEdgeChecker from principalmapper.graphing.sagemaker_edges import SageMakerEdgeChecker @@ -41,6 +42,7 @@ 'cloudformation': CloudFormationEdgeChecker, 'codebuild': CodeBuildEdgeChecker, 'ec2': EC2EdgeChecker, + 'glue': GlueEdgeChecker, 'iam': IAMEdgeChecker, 'lambda': LambdaEdgeChecker, 'sagemaker': SageMakerEdgeChecker, diff --git a/principalmapper/graphing/glue_edges.py b/principalmapper/graphing/glue_edges.py new file mode 100644 index 0000000..54abefc --- /dev/null +++ b/principalmapper/graphing/glue_edges.py @@ -0,0 +1,184 @@ +"""Code to identify if a principal in an AWS account can use access to AWS Glue to access other principals.""" + + +# Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. +# +# Principal Mapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Principal Mapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Principal Mapper. If not, see . + +import logging +from typing import Dict, List, Optional + +from botocore.exceptions import ClientError + +from principalmapper.common import Edge, Node +from principalmapper.graphing.edge_checker import EdgeChecker +from principalmapper.querying import query_interface +from principalmapper.querying.local_policy_simulation import resource_policy_authorization, ResourcePolicyEvalResult +from principalmapper.util import arns, botocore_tools + +logger = logging.getLogger(__name__) + + +class GlueEdgeChecker(EdgeChecker): + """Class for identifying if Glue can be used by IAM principals to gain access to other IAM principals.""" + + def return_edges(self, nodes: List[Node], region_allow_list: Optional[List[str]] = None, + region_deny_list: Optional[List[str]] = None, scps: Optional[List[List[dict]]] = None, + client_args_map: Optional[dict] = None, partition: str = 'aws') -> List[Edge]: + """Fulfills expected method return_edges.""" + + logger.info('Generating Edges based on Glue.') + + # Gather projects information for each region + + if client_args_map is None: + glueargs = {} + else: + glueargs = client_args_map.get('glue', {}) + + glue_clients = [] + if self.session is not None: + cf_regions = botocore_tools.get_regions_to_search(self.session, 'glue', region_allow_list, region_deny_list, partition) + for region in cf_regions: + glue_clients.append(self.session.create_client('glue', region_name=region, **glueargs)) + + for glue_client in glue_clients: + current_region = glue_client.meta.region_name + logger.debug(f'Looking at region {current_region}') + endpoint_role_list = [] + try: + # paginate thru existing Glue Dev Endpoints + for page in glue_client.get_paginator('get_dev_endpoints').paginate(): + for endpoint in page['DevEndpoints']: + role_node = None + if 'RoleArn' in endpoint: + for node in nodes: + if node.arn == endpoint['RoleArn']: + role_node = node + break + + if len(nodes) == 0: + break # causes false-negatives if there's no users/roles in the account + endpoint_arn = f'arn:{partition}:glue:{current_region}:{arns.get_account_id(nodes[0].arn)}:' \ + f'devEndpoint/{endpoint["EndpointName"]}' + endpoint_role_list.append((endpoint_arn, role_node)) + + except ClientError as ex: + logger.warning('Unable to search region {} for projects. The region may be disabled, or the error may ' + 'be caused by an authorization issue. Continuing.'.format(glue_client.meta.region_name)) + logger.debug('Exception details: {}'.format(ex)) + + result = generate_edges_locally(nodes, scps, endpoint_role_list) + + for edge in result: + logger.info("Found new edge: {}".format(edge.describe_edge())) + + return result + + +def generate_edges_locally(nodes: List[Node], scps: Optional[List[List[dict]]] = None, + endpoint_role_list: Optional[tuple] = None) -> List[Edge]: + + results = [] + + # to make things faster, we build a Role -> Endpoint map to reduce iterations through endpoint_role_list + node_endpoint_map = {} + for endpoint_arn, role_node in endpoint_role_list: + if role_node is not None and role_node not in node_endpoint_map: + node_endpoint_map[role_node] = [endpoint_arn] + else: + node_endpoint_map[role_node].append(endpoint_arn) + + # for all potential destination nodes... + for node_destination in nodes: + + # filter down to roles... + if ':role/' not in node_destination.arn: + continue + + # filter down to roles assumable by glue.amazonaws.com + sim_result = resource_policy_authorization( + 'glue.amazonaws.com', + arns.get_account_id(node_destination.arn), + node_destination.trust_policy, + 'sts:AssumeRole', + node_destination.arn, + {}, + ) + if sim_result != ResourcePolicyEvalResult.SERVICE_MATCH: + continue # Glue wasn't auth'd to assume the role + + for node_source in nodes: + # skip self-access checks + if node_source == node_destination: + continue + + # check if source is an admin: if so, it can access destination but this is not tracked via an Edge + if node_source.is_admin: + continue + + # check if source can use existing endpoints to access destination + if node_destination in node_endpoint_map: + for target_endpoint in node_endpoint_map[node_destination]: + update_ep_auth, update_ep_needs_mfa = query_interface.local_check_authorization_handling_mfa( + node_source, + 'glue:UpdateDevEndpoint', + target_endpoint, + {}, + service_control_policy_groups=scps + ) + if update_ep_auth: + if update_ep_needs_mfa: + reason = f'can use the Glue resource {target_endpoint} to access (needs MFA)' + else: + reason = f'can use the Glue resource {target_endpoint} to access' + results.append(Edge( + node_source, + node_destination, + reason, + 'Glue' + )) + break + + # check if source can create a new endpoint to access destination + passrole_auth, passrole_needs_mfa = query_interface.local_check_authorization_handling_mfa( + node_source, + 'iam:PassRole', + node_destination.arn, + {'iam:PassedToService': 'glue.amazonaws.com'}, + service_control_policy_groups=scps + ) + + if passrole_auth: + create_ep_auth, create_ep_needs_mfa = query_interface.local_check_authorization_handling_mfa( + node_source, + 'glue:CreateDevEndpoint', + '*', + {}, + service_control_policy_groups=scps + ) + + if create_ep_auth: + if passrole_needs_mfa or create_ep_needs_mfa: + reason = 'can call glue:CreateDevEndpoint to access (needs MFA)' + else: + reason = 'can call glue:CreateDevEndpoint to access' + results.append(Edge( + node_source, + node_destination, + reason, + 'Glue' + )) + + return results diff --git a/tests/build_test_graphs.py b/tests/build_test_graphs.py index ec6dbd9..fffb74f 100644 --- a/tests/build_test_graphs.py +++ b/tests/build_test_graphs.py @@ -32,7 +32,7 @@ def build_graph_with_one_admin() -> Graph: admin_user_arn = 'arn:aws:iam::000000000000:user/admin' policy = Policy(admin_user_arn, 'InlineAdminPolicy', _get_admin_policy()) node = Node(admin_user_arn, 'AIDA00000000000000000', [policy], [], None, None, 1, True, True, None, False, None) - return Graph([node], [], [policy], [], _get_default_metadata()) + return Graph([node], [], [policy], [], '000000000000', 'aws', _get_default_metadata()) # noinspection PyListCreation @@ -94,7 +94,7 @@ def build_playground_graph() -> Graph: # edges to add edges = obtain_edges(None, checker_map.keys(), nodes) - return Graph(nodes, edges, policies, [], _get_default_metadata()) + return Graph(nodes, edges, policies, [], '000000000000', 'aws', _get_default_metadata()) def _get_admin_policy() -> dict: @@ -237,7 +237,7 @@ def _get_s3_full_access_policy() -> dict: def _get_default_metadata() -> dict: """Constructs and returns a metadata dictionary to use across tests""" - return {'account_id': '000000000000', 'pmapper_version': principalmapper.__version__, 'partition': 'aws'} + return {'account_id': '000000000000', 'pmapper_version': principalmapper.__version__} def _make_trust_document(principal_element: dict) -> dict: diff --git a/tests/test_constructors.py b/tests/test_constructors.py index f508403..84240be 100644 --- a/tests/test_constructors.py +++ b/tests/test_constructors.py @@ -26,13 +26,13 @@ class ConstructorTest(unittest.TestCase): def test_graphs(self): with self.assertRaises(ValueError): - Graph(nodes=None, edges=[], policies=[], groups=[]) + Graph(nodes=None, edges=[], policies=[], groups=[], account='000000000000', partition='aws', metadata={}) with self.assertRaises(ValueError): - Graph(nodes=[], edges=None, policies=[], groups=[]) + Graph(nodes=[], edges=None, policies=[], groups=[], account='000000000000', partition='aws', metadata={}) with self.assertRaises(ValueError): - Graph(nodes=[], edges=[], policies=None, groups=[]) + Graph(nodes=[], edges=[], policies=None, groups=[], account='000000000000', partition='aws', metadata={}) with self.assertRaises(ValueError): - Graph(nodes=[], edges=[], policies=[], groups=None) + Graph(nodes=[], edges=[], policies=[], groups=None, account='000000000000', partition='aws', metadata={}) def test_nodes(self): with self.assertRaises(ValueError): From ef8e2efb0e5339af7cfc892a4783f03d188d6517 Mon Sep 17 00:00:00 2001 From: Erik Steringer Date: Fri, 28 Jan 2022 15:32:44 -0800 Subject: [PATCH 04/21] update glue edge gathering, fixed potential error --- principalmapper/graphing/glue_edges.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/principalmapper/graphing/glue_edges.py b/principalmapper/graphing/glue_edges.py index 54abefc..89fbb78 100644 --- a/principalmapper/graphing/glue_edges.py +++ b/principalmapper/graphing/glue_edges.py @@ -53,10 +53,11 @@ def return_edges(self, nodes: List[Node], region_allow_list: Optional[List[str]] for region in cf_regions: glue_clients.append(self.session.create_client('glue', region_name=region, **glueargs)) + endpoint_role_list = [] for glue_client in glue_clients: current_region = glue_client.meta.region_name logger.debug(f'Looking at region {current_region}') - endpoint_role_list = [] + try: # paginate thru existing Glue Dev Endpoints for page in glue_client.get_paginator('get_dev_endpoints').paginate(): @@ -88,17 +89,18 @@ def return_edges(self, nodes: List[Node], region_allow_list: Optional[List[str]] def generate_edges_locally(nodes: List[Node], scps: Optional[List[List[dict]]] = None, - endpoint_role_list: Optional[tuple] = None) -> List[Edge]: + endpoint_role_list: Optional[List[tuple]] = None) -> List[Edge]: results = [] # to make things faster, we build a Role -> Endpoint map to reduce iterations through endpoint_role_list node_endpoint_map = {} - for endpoint_arn, role_node in endpoint_role_list: - if role_node is not None and role_node not in node_endpoint_map: - node_endpoint_map[role_node] = [endpoint_arn] - else: - node_endpoint_map[role_node].append(endpoint_arn) + if endpoint_role_list is not None: + for endpoint_arn, role_node in endpoint_role_list: + if role_node is not None and role_node not in node_endpoint_map: + node_endpoint_map[role_node] = [endpoint_arn] + else: + node_endpoint_map[role_node].append(endpoint_arn) # for all potential destination nodes... for node_destination in nodes: From dc7ab36c5cfc1661f5dfe293bc8e8a5b9abd80c0 Mon Sep 17 00:00:00 2001 From: Erik Steringer Date: Mon, 31 Jan 2022 09:38:49 -0800 Subject: [PATCH 05/21] implement fix for #108, initial work for Data Pipeline edges --- .../graphing/datapipeline_edges.py | 85 +++++++++++++++++++ principalmapper/querying/query_interface.py | 13 +-- principalmapper/querying/query_utils.py | 21 +++-- 3 files changed, 102 insertions(+), 17 deletions(-) create mode 100644 principalmapper/graphing/datapipeline_edges.py diff --git a/principalmapper/graphing/datapipeline_edges.py b/principalmapper/graphing/datapipeline_edges.py new file mode 100644 index 0000000..c9ce735 --- /dev/null +++ b/principalmapper/graphing/datapipeline_edges.py @@ -0,0 +1,85 @@ +"""Code to identify if a principal in an AWS account can use access to AWS Data Pipeline to access other principals.""" + + +# Copyright (c) NCC Group and Erik Steringer 2019. This file is part of Principal Mapper. +# +# Principal Mapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Principal Mapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Principal Mapper. If not, see . + +import logging +from typing import Dict, List, Optional + +from botocore.exceptions import ClientError + +from principalmapper.common import Edge, Node +from principalmapper.graphing.edge_checker import EdgeChecker +from principalmapper.querying import query_interface +from principalmapper.querying.local_policy_simulation import resource_policy_authorization, ResourcePolicyEvalResult +from principalmapper.util import arns, botocore_tools + +logger = logging.getLogger(__name__) + + +class DataPipelineEdgeChecker(EdgeChecker): + """Class for identifying if Data Pipeline can be used by IAM principals to gain access to other IAM principals.""" + + def return_edges(self, nodes: List[Node], region_allow_list: Optional[List[str]] = None, + region_deny_list: Optional[List[str]] = None, scps: Optional[List[List[dict]]] = None, + client_args_map: Optional[dict] = None, partition: str = 'aws') -> List[Edge]: + """Fulfills expected method return_edges.""" + + logger.info('Generating Edges based on Data Pipeline.') + + result = generate_edges_locally(nodes, scps) + + for edge in result: + logger.info("Found new edge: {}".format(edge.describe_edge())) + + return result + + +def generate_edges_locally(nodes: List[Node], scps: Optional[List[List[dict]]] = None) -> List[Edge]: + """For Data Pipeline, we do something a little different. The way people can use DataPipeline to pivot is + to create a pipeline, then put a definition on the pipeline that creates an EC2 instance resource. The + role that's used by the EC2 instance is the ultimate target. This requires: + + * datapipeline:CreatePipeline (resource "*") + * datapipeline:PutPipelineDefinition (resource "*") + * iam:PassRole for the Data Pipeline Role (which must trust datapipeline.amazonaws.com) + * (TODO: Verify) iam:PassRole for the EC2 Data Pipeline Role (which must trust ec2.amazonaws.com and have an instance profile) + + Note that we have two roles involved. Data Pipeline Role, which seems to be a sorta service role but + doesn't have the same path/naming convention as other service roles, is used to actually call EC2 and + spin up the target instance. It's meant to be accessible to datapipeline.amazonaws.com. Then, we have + the EC2 Data Pipeline Role, which actually is accessible to the EC2 instance doing the computational + work of the pipeline. + + Other works seemed to indicate the Data Pipeline Role was accessible, however that might not be true + anymore? In any case, recent experimentation only allowed me access to the EC2 Data Pipeline Role. + + To create the list of edges, we gather our: + + * Potential Data Pipeline Roles + * Potential EC2 Data Pipeline Roles + + Then we determine which of the EC2 roles are accessible to the Data Pipeline Roles, then run through all + potential source nodes to see if they have the correct datapipeline:* + iam:PassRole permissions, then generate + edges that have the EC2 roles as destinations. + + This vector is neat because even if specific EC2-accessible roles are blocked via ec2:RunInstances, this might be + an alternative option the same as autoscaling was. + """ + + results = [] + + return results diff --git a/principalmapper/querying/query_interface.py b/principalmapper/querying/query_interface.py index fd5366e..376be89 100644 --- a/principalmapper/querying/query_interface.py +++ b/principalmapper/querying/query_interface.py @@ -288,7 +288,7 @@ def local_check_authorization_full(principal: Node, action_to_check: str, resour prepped_condition_keys = _prepare_condition_context(conditions_keys_copy) prepped_condition_keys.update(_infer_condition_keys(principal, prepped_condition_keys)) - is_not_service_linked_role = not _check_if_service_linked_role(principal) + is_not_service_linked_role = not query_utils.check_if_service_linked_role(principal) logger.debug( 'Testing authorization for: principal: {}, action: {}, resource: {}, conditions: {}, Resource Policy: {}, SCPs: {}, Session Policy: {}'.format( @@ -402,17 +402,6 @@ def local_check_authorization_full(principal: Node, action_to_check: str, resour return False -def _check_if_service_linked_role(principal: Node) -> bool: - """Given a Node, determine if it should be treated as a service-linked role. This affects SCP policy decisions as - described in - https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_policies_scps.html#not-restricted-by-scp""" - - if ':role/' in principal.arn: - role_name = principal.arn.split('/')[-1] - return role_name.startswith('AWSServiceRoleFor') - return False - - def simulation_api_check_authorization(iamclient, principal: Node, action_to_check: str, resource_to_check: str, condition_keys_to_check: dict) -> bool: """DO NOT USE THIS FUNCTION, IT WILL ONLY THROW A NotImplementedError.""" diff --git a/principalmapper/querying/query_utils.py b/principalmapper/querying/query_utils.py index 36b336c..5ecc6bf 100644 --- a/principalmapper/querying/query_utils.py +++ b/principalmapper/querying/query_utils.py @@ -36,10 +36,11 @@ def get_search_list(graph: Graph, node: Node) -> List[List[Edge]]: result = [] explored_nodes = [] - # Special-case: node is an "admin", so we make up admin edges and return them all + # Special-case: node is an "admin", so we make up admin edges and return them all. BUT, if the destination + # node is the original node or a service-linked role, then we skip those if node.is_admin: for other_node in graph.nodes: - if node == other_node: + if node == other_node or check_if_service_linked_role(other_node): continue result.append([Edge(node, other_node, 'can access through administrative actions', 'Admin')]) return result @@ -225,9 +226,8 @@ def get_interaccount_search_list(all_graphs: List[Graph], inter_account_edges: L def get_edges_interaccount(source_graph: Graph, inter_account_edges: List[Edge], node: Node, ignored_nodes: List[Node]) -> List[Edge]: """Given a Node, the Graph it belongs to, a list of inter-account Edges, and a list of Nodes to skip, this returns - any Edges where the Node is the source element as long as the destination element isn't included in the skipped Nodes. - - If the given node is an admin, those Edge objects get generated and returned. + any Edges where the Node is the source element as long as the destination element isn't included in the skipped + Nodes. """ result = [] @@ -241,3 +241,14 @@ def get_edges_interaccount(source_graph: Graph, inter_account_edges: List[Edge], result.append(inter_account_edge) return result + + +def check_if_service_linked_role(principal: Node) -> bool: + """Given a Node, determine if it should be treated as a service-linked role. This affects SCP policy decisions as + described in + https://docs.aws.amazon.com/organizations/latest/userguide/orgs_manage_policies_scps.html#not-restricted-by-scp""" + + if ':role/' in principal.arn: + role_name = principal.arn.split('/')[-1] + return role_name.startswith('AWSServiceRoleFor') + return False From 531a1ca891fb8bbbb7d7d9f9f83b186a5e8e2cba Mon Sep 17 00:00:00 2001 From: Erik Steringer Date: Mon, 31 Jan 2022 11:32:01 -0800 Subject: [PATCH 06/21] implementation of Data Pipeline edge checks --- .../graphing/datapipeline_edges.py | 114 +++++++++++++++++- .../graphing/edge_identification.py | 2 + 2 files changed, 114 insertions(+), 2 deletions(-) diff --git a/principalmapper/graphing/datapipeline_edges.py b/principalmapper/graphing/datapipeline_edges.py index c9ce735..804fb5b 100644 --- a/principalmapper/graphing/datapipeline_edges.py +++ b/principalmapper/graphing/datapipeline_edges.py @@ -56,7 +56,7 @@ def generate_edges_locally(nodes: List[Node], scps: Optional[List[List[dict]]] = * datapipeline:CreatePipeline (resource "*") * datapipeline:PutPipelineDefinition (resource "*") * iam:PassRole for the Data Pipeline Role (which must trust datapipeline.amazonaws.com) - * (TODO: Verify) iam:PassRole for the EC2 Data Pipeline Role (which must trust ec2.amazonaws.com and have an instance profile) + * iam:PassRole for the EC2 Data Pipeline Role (which must trust ec2.amazonaws.com and have an instance profile) Note that we have two roles involved. Data Pipeline Role, which seems to be a sorta service role but doesn't have the same path/naming convention as other service roles, is used to actually call EC2 and @@ -64,7 +64,7 @@ def generate_edges_locally(nodes: List[Node], scps: Optional[List[List[dict]]] = the EC2 Data Pipeline Role, which actually is accessible to the EC2 instance doing the computational work of the pipeline. - Other works seemed to indicate the Data Pipeline Role was accessible, however that might not be true + Other works/blogs seemed to indicate the Data Pipeline Role was accessible, however that might not be true anymore? In any case, recent experimentation only allowed me access to the EC2 Data Pipeline Role. To create the list of edges, we gather our: @@ -82,4 +82,114 @@ def generate_edges_locally(nodes: List[Node], scps: Optional[List[List[dict]]] = results = [] + intermediate_node_paths = {} + destination_nodes = [] + for node in nodes: + if ':role/' not in node.arn: + continue + + rp_result = resource_policy_authorization( + 'datapipeline.amazonaws.com', + arns.get_account_id(node.arn), + node.trust_policy, + 'sts:AssumeRole', + node.arn, + {} + ) + + if rp_result is ResourcePolicyEvalResult.SERVICE_MATCH: + intermediate_node_paths[node] = [] + + rp_result = resource_policy_authorization( + 'ec2.amazonaws.com', + arns.get_account_id(node.arn), + node.trust_policy, + 'sts:AssumeRole', + node.arn, + {} + ) + + if rp_result is ResourcePolicyEvalResult.SERVICE_MATCH and node.instance_profile is not None: + destination_nodes.append(node) + + for intermediate_node in intermediate_node_paths.keys(): + for destination_node in destination_nodes: + # if intermediate can run EC2 and pass the role, then add that path for checking + if not query_interface.local_check_authorization( + intermediate_node, + 'ec2:RunInstances', + '*', + {'ec2:InstanceProfile': destination_node.instance_profile} + ): + continue + + if query_interface.local_check_authorization( + intermediate_node, + 'iam:PassRole', + destination_node.arn, + {'iam:PassedToService': 'ec2.amazonaws.com'} + ): + intermediate_node_paths[intermediate_node].append(destination_node) + + # now we have the mappings for -> paths + for node_source in nodes: + if node_source.is_admin: + continue + + create_pipeline_auth, cpa_mfa = query_interface.local_check_authorization_handling_mfa( + node_source, + 'datapipeline:CreatePipeline', + '*', + {}, + service_control_policy_groups=scps + ) + if not create_pipeline_auth: + continue + + put_pipeline_def_auth, ppda_mfa = query_interface.local_check_authorization_handling_mfa( + node_source, + 'datapipeline:PutPipelineDefinition', + '*', + {}, + service_control_policy_groups=scps + ) + if not put_pipeline_def_auth: + continue + + for intermediate_node in intermediate_node_paths.keys(): + intermediate_node_auth, ina_mfa = query_interface.local_check_authorization_handling_mfa( + node_source, + 'iam:PassRole', + intermediate_node.arn, + {'iam:PassedToService': 'datapipeline.amazonaws.com'}, + service_control_policy_groups=scps + ) + if not intermediate_node_auth: + continue # can't use the intermediate to get to the destinations, so we move on + + for destination_node in destination_nodes: + if node_source == destination_node: + continue + + destination_node_auth, dna_mfa = query_interface.local_check_authorization_handling_mfa( + node_source, + 'iam:PassRole', + destination_node.arn, + {}, + service_control_policy_groups=scps + ) + if destination_node_auth: + if cpa_mfa or ppda_mfa or ina_mfa or dna_mfa: + reason = f'can use Data Pipeline with {intermediate_node.searchable_name()} to access ' \ + f'(needs MFA)' + else: + reason = f'can use Data Pipeline with {intermediate_node.searchable_name()} to access' + + results.append(Edge( + node_source, + destination_node, + reason, + 'Data Pipeline' + )) + return results diff --git a/principalmapper/graphing/edge_identification.py b/principalmapper/graphing/edge_identification.py index c69be18..5e7060b 100644 --- a/principalmapper/graphing/edge_identification.py +++ b/principalmapper/graphing/edge_identification.py @@ -24,6 +24,7 @@ from principalmapper.graphing.autoscaling_edges import AutoScalingEdgeChecker from principalmapper.graphing.cloudformation_edges import CloudFormationEdgeChecker from principalmapper.graphing.codebuild_edges import CodeBuildEdgeChecker +from principalmapper.graphing.datapipeline_edges import DataPipelineEdgeChecker from principalmapper.graphing.ec2_edges import EC2EdgeChecker from principalmapper.graphing.glue_edges import GlueEdgeChecker from principalmapper.graphing.iam_edges import IAMEdgeChecker @@ -41,6 +42,7 @@ 'autoscaling': AutoScalingEdgeChecker, 'cloudformation': CloudFormationEdgeChecker, 'codebuild': CodeBuildEdgeChecker, + 'datapipeline': DataPipelineEdgeChecker, 'ec2': EC2EdgeChecker, 'glue': GlueEdgeChecker, 'iam': IAMEdgeChecker, From 1ae41dcde5a6997a562332e419a6cddcdefb41e8 Mon Sep 17 00:00:00 2001 From: Erik Steringer Date: Wed, 2 Feb 2022 11:40:11 -0800 Subject: [PATCH 07/21] fixes: verified graph creation + query works with aws-us-gov account --- principalmapper/graphing/gathering.py | 3 ++- principalmapper/graphing/graph_cli.py | 9 +++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/principalmapper/graphing/gathering.py b/principalmapper/graphing/gathering.py index 7bd56c8..6f06903 100644 --- a/principalmapper/graphing/gathering.py +++ b/principalmapper/graphing/gathering.py @@ -89,7 +89,8 @@ def create_graph(session: botocore.session.Session, service_list: list, region_a region_allow_list, region_deny_list, scps, - client_args_map + client_args_map, + current_partition ) # Pull S3, SNS, SQS, KMS, and Secrets Manager resource policies diff --git a/principalmapper/graphing/graph_cli.py b/principalmapper/graphing/graph_cli.py index 6088f18..a3e52a2 100644 --- a/principalmapper/graphing/graph_cli.py +++ b/principalmapper/graphing/graph_cli.py @@ -29,7 +29,7 @@ from principalmapper.graphing.edge_identification import checker_map from principalmapper.querying import query_orgs from principalmapper.util import botocore_tools -from principalmapper.util.storage import get_storage_root +from principalmapper.util.storage import get_storage_root, get_default_graph_path logger = logging.getLogger(__name__) @@ -179,7 +179,12 @@ def process_arguments(parsed_args: Namespace): graph = graph_actions.create_new_graph(session, service_list, parsed_args.include_regions, parsed_args.exclude_regions, scps, client_args_map) graph_actions.print_graph_data(graph) - graph.store_graph_as_json(os.path.join(get_storage_root(), graph.account)) + if graph.partition == 'aws': + graphid = graph.account + else: + graphid = f'{graph.partition}:{graph.account}' + + graph.store_graph_as_json(get_default_graph_path(graphid)) elif parsed_args.picked_graph_cmd == 'display': if parsed_args.account is None: From d429e0e069dbc77618a00dc78e0929a46dc5fcdb Mon Sep 17 00:00:00 2001 From: Erik Steringer Date: Wed, 2 Feb 2022 13:46:58 -0800 Subject: [PATCH 08/21] refactored how service-linked roles are determined --- principalmapper/common/nodes.py | 12 +++++++++++- principalmapper/querying/query_interface.py | 2 +- principalmapper/querying/query_utils.py | 2 +- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/principalmapper/common/nodes.py b/principalmapper/common/nodes.py index 551c35c..7e85ac2 100644 --- a/principalmapper/common/nodes.py +++ b/principalmapper/common/nodes.py @@ -105,7 +105,8 @@ def get_outbound_edges(self, graph): # -> List[Edge], can't import Edge/Graph i self.cache['outbound_edges'] = [] if self.is_admin: for node in graph.nodes: - if node == self: + # skip self-links and links to service-linked roles (not even accessible to admins) + if node == self or node.is_service_linked_role(): continue else: self.cache['outbound_edges'].append( @@ -119,6 +120,15 @@ def get_outbound_edges(self, graph): # -> List[Edge], can't import Edge/Graph i self.cache['outbound_edges'].append(edge) return self.cache['outbound_edges'] + def is_service_linked_role(self): + if 'is_service_linked_role' not in self.cache: + if ':role/' in self.arn: + role_name = self.arn.split('/')[-1] + self.cache['is_service_linked_role'] = role_name.startswith('AWSServiceRoleFor') + else: + self.cache['is_service_linked_role'] = False + return self.cache['is_service_linked_role'] + def to_dictionary(self) -> dict: """Creates a dictionary representation of this Node for storage.""" _pb = self.permissions_boundary diff --git a/principalmapper/querying/query_interface.py b/principalmapper/querying/query_interface.py index 376be89..d742487 100644 --- a/principalmapper/querying/query_interface.py +++ b/principalmapper/querying/query_interface.py @@ -288,7 +288,7 @@ def local_check_authorization_full(principal: Node, action_to_check: str, resour prepped_condition_keys = _prepare_condition_context(conditions_keys_copy) prepped_condition_keys.update(_infer_condition_keys(principal, prepped_condition_keys)) - is_not_service_linked_role = not query_utils.check_if_service_linked_role(principal) + is_not_service_linked_role = not principal.is_service_linked_role() logger.debug( 'Testing authorization for: principal: {}, action: {}, resource: {}, conditions: {}, Resource Policy: {}, SCPs: {}, Session Policy: {}'.format( diff --git a/principalmapper/querying/query_utils.py b/principalmapper/querying/query_utils.py index 5ecc6bf..8f412ff 100644 --- a/principalmapper/querying/query_utils.py +++ b/principalmapper/querying/query_utils.py @@ -40,7 +40,7 @@ def get_search_list(graph: Graph, node: Node) -> List[List[Edge]]: # node is the original node or a service-linked role, then we skip those if node.is_admin: for other_node in graph.nodes: - if node == other_node or check_if_service_linked_role(other_node): + if node == other_node or other_node.is_service_linked_role(): continue result.append([Edge(node, other_node, 'can access through administrative actions', 'Admin')]) return result From 36a7b4e214416fd895b8d8d2cf9f5f602b027802 Mon Sep 17 00:00:00 2001 From: Erik Steringer Date: Wed, 2 Feb 2022 15:03:46 -0800 Subject: [PATCH 09/21] added support for AWS China SP (untested) --- principalmapper/graphing/ec2_edges.py | 33 +++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/principalmapper/graphing/ec2_edges.py b/principalmapper/graphing/ec2_edges.py index fb20143..b5145fd 100644 --- a/principalmapper/graphing/ec2_edges.py +++ b/principalmapper/graphing/ec2_edges.py @@ -40,7 +40,7 @@ def return_edges(self, nodes: List[Node], region_allow_list: Optional[List[str]] """Fulfills expected method return_edges.""" logger.info('Generating Edges based on EC2.') - result = generate_edges_locally(nodes, scps) + result = generate_edges_locally(nodes, scps, partition) for edge in result: logger.info("Found new edge: {}".format(edge.describe_edge())) @@ -48,7 +48,7 @@ def return_edges(self, nodes: List[Node], region_allow_list: Optional[List[str]] return result -def generate_edges_locally(nodes: List[Node], scps: Optional[List[List[dict]]] = None) -> List[Edge]: +def generate_edges_locally(nodes: List[Node], scps: Optional[List[List[dict]]] = None, partition: str = 'aws') -> List[Edge]: """Generates and returns Edge objects. It is possible to use this method if you are operating offline (infra-as-code). """ @@ -69,7 +69,20 @@ def generate_edges_locally(nodes: List[Node], scps: Optional[List[List[dict]]] = ) if sim_result != ResourcePolicyEvalResult.SERVICE_MATCH: - continue # EC2 wasn't auth'd to assume the role + if partition != 'aws-cn': + continue + else: + # special case: AWS China uses ec2.amazonaws.com.cn as a service principal, so retry + sim_result = resource_policy_authorization( + 'ec2.amazonaws.com.cn', + arns.get_account_id(node_destination.arn), + node_destination.trust_policy, + 'sts:AssumeRole', + node_destination.arn, + {}, + ) + if sim_result != ResourcePolicyEvalResult.SERVICE_MATCH: + continue for node_source in nodes: # skip self-access checks @@ -91,7 +104,19 @@ def generate_edges_locally(nodes: List[Node], scps: Optional[List[List[dict]]] = service_control_policy_groups=scps ) if not pass_role_auth: - continue # source can't pass the role to use it + if partition != 'aws-cn': + continue # source can't pass the role to use it + else: + # try again with AWS China EC2 SP + pass_role_auth, mfa_res = query_interface.local_check_authorization_handling_mfa( + node_source, + 'iam:PassRole', + node_destination.arn, + {'iam:PassedToService': 'ec2.amazonaws.com.cn'}, + service_control_policy_groups=scps + ) + if not pass_role_auth: + continue # check if destination has an instance profile, if not: check if source can create it if node_destination.instance_profile is None: From f34a4db47714493b15092a10e6b2c343db91cdfd Mon Sep 17 00:00:00 2001 From: Erik Steringer Date: Thu, 3 Feb 2022 15:36:27 -0800 Subject: [PATCH 10/21] fix example script --- examples/graph_from_cf_template.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/graph_from_cf_template.py b/examples/graph_from_cf_template.py index 028a01f..4217a47 100644 --- a/examples/graph_from_cf_template.py +++ b/examples/graph_from_cf_template.py @@ -144,7 +144,7 @@ def main(): edges = iam_edges.generate_edges_locally(nodes) + sts_edges.generate_edges_locally(nodes) # Create our graph and finish - graph = Graph(nodes, edges, policies, groups, metadata) + graph = Graph(nodes, edges, policies, groups, '000000000000', 'aws', metadata) graph_actions.print_graph_data(graph) From 94a14b91148c20715fbb30b58dd0823cc291e782 Mon Sep 17 00:00:00 2001 From: Erik Steringer Date: Thu, 3 Feb 2022 15:47:34 -0800 Subject: [PATCH 11/21] add --version commandline arg --- principalmapper/__main__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/principalmapper/__main__.py b/principalmapper/__main__.py index 2a4e669..1f372b8 100644 --- a/principalmapper/__main__.py +++ b/principalmapper/__main__.py @@ -21,6 +21,7 @@ import logging import sys +import principalmapper from principalmapper.analysis import cli as analysis_cli from principalmapper.graphing import graph_cli from principalmapper.graphing import orgs_cli @@ -49,6 +50,11 @@ def main() -> int: action='store_true', help='Produces debug-level output of the underlying Principal Mapper library during execution.' ) + argument_parser.add_argument( + '--version', + action='version', + version=f'Principal Mapper v{principalmapper.__version__}' + ) # Create subparser for various subcommands subparser = argument_parser.add_subparsers( From 1a2c80f2937b901b3cee868b5ac17855e8f739aa Mon Sep 17 00:00:00 2001 From: Erik Steringer Date: Thu, 3 Feb 2022 17:01:33 -0800 Subject: [PATCH 12/21] fixed up AWS Organizations code for non-aws partitions --- principalmapper/graphing/graph_cli.py | 8 ++++-- principalmapper/graphing/orgs_cli.py | 35 ++++++++++++++++++++------- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/principalmapper/graphing/graph_cli.py b/principalmapper/graphing/graph_cli.py index a3e52a2..5fb2869 100644 --- a/principalmapper/graphing/graph_cli.py +++ b/principalmapper/graphing/graph_cli.py @@ -28,7 +28,7 @@ from principalmapper.graphing.gathering import get_organizations_data from principalmapper.graphing.edge_identification import checker_map from principalmapper.querying import query_orgs -from principalmapper.util import botocore_tools +from principalmapper.util import botocore_tools, arns from principalmapper.util.storage import get_storage_root, get_default_graph_path @@ -149,9 +149,13 @@ def process_arguments(parsed_args: Namespace): stsclient = session.create_client('sts') caller_identity = stsclient.get_caller_identity() caller_account = caller_identity['Account'] + partition = arns.get_partition(caller_identity['Arn']) logger.debug("Caller Identity: {}".format(caller_identity)) - org_tree_search_dir = Path(get_storage_root()) + if partition == 'aws': + org_tree_search_dir = Path(get_storage_root()) + else: + org_tree_search_dir = Path(os.path.join(get_storage_root(), partition)) org_id_pattern = re.compile(r'/o-\w+') for subdir in org_tree_search_dir.iterdir(): if org_id_pattern.search(str(subdir)) is not None: diff --git a/principalmapper/graphing/orgs_cli.py b/principalmapper/graphing/orgs_cli.py index 34c33d9..b55cd44 100644 --- a/principalmapper/graphing/orgs_cli.py +++ b/principalmapper/graphing/orgs_cli.py @@ -29,7 +29,7 @@ from principalmapper.graphing.gathering import get_organizations_data from principalmapper.querying.query_orgs import produce_scp_list from principalmapper.util import botocore_tools -from principalmapper.util.storage import get_storage_root +from principalmapper.util.storage import get_storage_root, get_default_graph_path logger = logging.getLogger(__name__) @@ -106,7 +106,8 @@ def process_arguments(parsed_args: Namespace): # create the account -> OU path map and apply to all accounts (same as orgs update operation) account_ou_map = _map_account_ou_paths(org_tree) logger.debug('account_ou_map: {}'.format(account_ou_map)) - _update_accounts_with_ou_path_map(org_tree.org_id, account_ou_map, get_storage_root()) + root_path = get_storage_root() if org_tree.partition == 'aws' else os.path.join(get_storage_root(), org_tree.partition) + _update_accounts_with_ou_path_map(org_tree.org_id, account_ou_map, root_path) logger.info('Updated currently stored Graphs with applicable AWS Organizations data') # create and cache a list of edges between all the accounts we have data for @@ -114,7 +115,11 @@ def process_arguments(parsed_args: Namespace): graph_objs = [] for account in org_tree.accounts: try: - potential_path = os.path.join(get_storage_root(), account) + if org_tree.partition != 'aws': + potential_path = get_default_graph_path(f'{org_tree.partition}:{account}') + else: + potential_path = get_default_graph_path(account) + logger.debug('Trying to load a Graph from {}'.format(potential_path)) graph_obj = Graph.create_graph_from_local_disk(potential_path) graph_objs.append(graph_obj) @@ -135,18 +140,23 @@ def process_arguments(parsed_args: Namespace): org_tree.edge_list = edge_list logger.info('Compiled cross-account edges') - org_tree.save_organization_to_disk(os.path.join(get_storage_root(), org_tree.org_id)) + if org_tree.partition != 'aws': + org_storage_path = get_default_graph_path(f'{org_tree.partition}:{org_tree.org_id}') + else: + org_storage_path = get_default_graph_path(org_tree.org_id) + org_tree.save_organization_to_disk(org_storage_path) logger.info('Stored organization data to disk') elif parsed_args.picked_orgs_cmd == 'update': # pull the existing data from disk - org_filepath = os.path.join(get_storage_root(), parsed_args.org) + org_filepath = get_default_graph_path(parsed_args.org) org_tree = OrganizationTree.create_from_dir(org_filepath) # create the account -> OU path map and apply to all accounts account_ou_map = _map_account_ou_paths(org_tree) logger.debug('account_ou_map: {}'.format(account_ou_map)) - _update_accounts_with_ou_path_map(org_tree.org_id, account_ou_map, get_storage_root()) + root_path = get_storage_root() if org_tree.partition == 'aws' else os.path.join(get_storage_root(), org_tree.partition) + _update_accounts_with_ou_path_map(org_tree.org_id, account_ou_map, root_path) logger.info('Updated currently stored Graphs with applicable AWS Organizations data') # create and cache a list of edges between all the accounts we have data for @@ -154,7 +164,10 @@ def process_arguments(parsed_args: Namespace): graph_objs = [] for account in org_tree.accounts: try: - potential_path = os.path.join(get_storage_root(), account) + if org_tree.partition != 'aws': + potential_path = get_default_graph_path(f'{org_tree.partition}:{account}') + else: + potential_path = get_default_graph_path(account) logger.debug('Trying to load a Graph from {}'.format(potential_path)) graph_obj = Graph.create_graph_from_local_disk(potential_path) graph_objs.append(graph_obj) @@ -175,12 +188,16 @@ def process_arguments(parsed_args: Namespace): org_tree.edge_list = edge_list logger.info('Compiled cross-account edges') - org_tree.save_organization_to_disk(os.path.join(get_storage_root(), org_tree.org_id)) + if org_tree.partition != 'aws': + org_storage_path = get_default_graph_path(f'{org_tree.partition}:{org_tree.org_id}') + else: + org_storage_path = get_default_graph_path(org_tree.org_id) + org_tree.save_organization_to_disk(org_storage_path) logger.info('Stored organization data to disk') elif parsed_args.picked_orgs_cmd == 'display': # pull the existing data from disk - org_filepath = os.path.join(get_storage_root(), parsed_args.org) + org_filepath = get_default_graph_path(parsed_args.org) org_tree = OrganizationTree.create_from_dir(org_filepath) def _print_account(org_account: OrganizationAccount, indent_level: int, inherited_scps: List[Policy]): From 8ef6b7ec63df4cb06f332b6e947b02635b4b6881 Mon Sep 17 00:00:00 2001 From: Erik Steringer Date: Fri, 4 Feb 2022 11:49:35 -0800 Subject: [PATCH 13/21] add parameter checks for Graph objects (help with upgrading 1.1 -> 1.2) --- principalmapper/common/graphs.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/principalmapper/common/graphs.py b/principalmapper/common/graphs.py index d34c70b..50cdacb 100644 --- a/principalmapper/common/graphs.py +++ b/principalmapper/common/graphs.py @@ -56,8 +56,18 @@ def __init__(self, nodes: list, edges: list, policies: list, groups: list, accou self.edges = edges self.policies = policies self.groups = groups + self.account = account + if not isinstance(account, str): + raise ValueError('Parameter `account` is expected to be str') + self.partition = partition + if not isinstance(partition, str): + raise ValueError('Parameter `partition` is expected to be str') + + if not isinstance(metadata, dict): + raise ValueError('Parameter `metadata` is expected to be dict') + if 'pmapper_version' not in metadata: raise ValueError('Incomplete metadata input, expected key: "pmapper_version"') self.metadata = metadata From bfe2e0f4479e6a0c1db9056507138c8410f4c9dd Mon Sep 17 00:00:00 2001 From: Erik Steringer Date: Fri, 4 Feb 2022 16:11:11 -0800 Subject: [PATCH 14/21] initial ECS edges, made edge labels more consistent --- .../graphing/datapipeline_edges.py | 3 +- principalmapper/graphing/ecs_edges.py | 134 ++++++++++++++++++ .../graphing/edge_identification.py | 2 + principalmapper/graphing/glue_edges.py | 4 +- principalmapper/graphing/ssm_edges.py | 4 +- 5 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 principalmapper/graphing/ecs_edges.py diff --git a/principalmapper/graphing/datapipeline_edges.py b/principalmapper/graphing/datapipeline_edges.py index 804fb5b..607dc89 100644 --- a/principalmapper/graphing/datapipeline_edges.py +++ b/principalmapper/graphing/datapipeline_edges.py @@ -180,8 +180,7 @@ def generate_edges_locally(nodes: List[Node], scps: Optional[List[List[dict]]] = ) if destination_node_auth: if cpa_mfa or ppda_mfa or ina_mfa or dna_mfa: - reason = f'can use Data Pipeline with {intermediate_node.searchable_name()} to access ' \ - f'(needs MFA)' + reason = f'(requires MFA) can use Data Pipeline with {intermediate_node.searchable_name()} to access ' else: reason = f'can use Data Pipeline with {intermediate_node.searchable_name()} to access' diff --git a/principalmapper/graphing/ecs_edges.py b/principalmapper/graphing/ecs_edges.py new file mode 100644 index 0000000..e811545 --- /dev/null +++ b/principalmapper/graphing/ecs_edges.py @@ -0,0 +1,134 @@ +"""Code to identify if a principal in an AWS account can use access to ECS to access other principals.""" + + +# Copyright (c) NCC Group and Erik Steringer 2022. This file is part of Principal Mapper. +# +# Principal Mapper is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Principal Mapper is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Principal Mapper. If not, see . + +import io +import logging +import os +from typing import List, Optional + +from principalmapper.common import Edge, Node +from principalmapper.graphing.edge_checker import EdgeChecker +from principalmapper.querying import query_interface +from principalmapper.querying.local_policy_simulation import resource_policy_authorization, ResourcePolicyEvalResult +from principalmapper.util import arns + + +logger = logging.getLogger(__name__) + + +class ECSEdgeChecker(EdgeChecker): + """Class for identifying if ECS can be used by IAM principals to gain access to other IAM principals.""" + + def return_edges(self, nodes: List[Node], region_allow_list: Optional[List[str]] = None, + region_deny_list: Optional[List[str]] = None, scps: Optional[List[List[dict]]] = None, + client_args_map: Optional[dict] = None, partition: str = 'aws') -> List[Edge]: + """Fulfills expected method return_edges.""" + + logger.info('Generating Edges based on ECS.') + result = generate_edges_locally(nodes, scps, partition) + + for edge in result: + logger.info("Found new edge: {}".format(edge.describe_edge())) + + return result + + +def generate_edges_locally(nodes: List[Node], scps: Optional[List[List[dict]]] = None, partition: str = 'aws') -> List[Edge]: + """Generates and returns Edge objects. It is possible to use this method if you are operating offline (infra-as-code). + """ + + # TODO: pull and include existing clusters, tasks, services + + result = [] + + service_linked_role_exists = False + for node in nodes: + if ':role/aws-service-role/ecs.amazonaws.com/AWSServiceRoleForECS' in node.arn: + service_linked_role_exists = True # can update to point to node if we need to do intermediate checks + break + + for node_destination in nodes: + if ':role/' not in node_destination.arn: + continue + + sim_result = resource_policy_authorization( + 'ecs-tasks.amazonaws.com', + arns.get_account_id(node_destination.arn), + node_destination.trust_policy, + 'sts:AssumeRole', + node_destination.arn, + {} + ) + if sim_result is not ResourcePolicyEvalResult.SERVICE_MATCH: + continue + + for node_source in nodes: + if node_source == node_destination: + continue + + if node_source.is_admin: + continue + + # check that either the service-linked role exists or needs to be created + create_slr_auth = False + create_slr_mfa = False + if not service_linked_role_exists: + # using auth/mfa var, since the control flow continues to the next loop if we cannot make the SLR + create_slr_auth, create_slr_mfa = query_interface.local_check_authorization_handling_mfa( + node_source, + 'iam:CreateServiceLinkedRole', + f'arn:aws:iam::{arns.get_account_id(node_source.arn)}:role/aws-service-role/ecs.amazonaws.com/AWSServiceRoleForECS', + {'iam:AWSServiceName': 'ecs.amazonaws.com'}, + service_control_policy_groups=scps + ) + if not create_slr_auth: + continue # can't make the service-linked role -> can't use ECS (?) + + # check if someone can pass this role as an ECS Task Role + pass_role_auth, pass_role_mfa = query_interface.local_check_authorization_handling_mfa( + node_source, + 'iam:PassRole', + node_destination.arn, + {'iam:PassedToService': 'ecs-tasks.amazonaws.com'}, # verified via managed policies, + service_control_policy_groups=scps + ) + + if not pass_role_auth: + continue + + # check if someone can start/run a task + run_task_auth, run_task_mfa = query_interface.local_check_authorization_handling_mfa( + node_source, + 'ecs:RunTask', + '*', + {}, + service_control_policy_groups=scps + ) + + if not run_task_auth: + continue + + reason = f'{"(requires MFA) " if create_slr_mfa or pass_role_mfa or run_task_mfa else ""}can ' \ + f'{"use the existing ECS Service-Linked Role" if service_linked_role_exists else "create the ECS Service-Linked Role"} ' \ + f'to run a task in ECS and access ' + + result.append(Edge( + node_source, node_destination, reason, 'ECS' + )) + + return result \ No newline at end of file diff --git a/principalmapper/graphing/edge_identification.py b/principalmapper/graphing/edge_identification.py index 5e7060b..382115d 100644 --- a/principalmapper/graphing/edge_identification.py +++ b/principalmapper/graphing/edge_identification.py @@ -26,6 +26,7 @@ from principalmapper.graphing.codebuild_edges import CodeBuildEdgeChecker from principalmapper.graphing.datapipeline_edges import DataPipelineEdgeChecker from principalmapper.graphing.ec2_edges import EC2EdgeChecker +from principalmapper.graphing.ecs_edges import ECSEdgeChecker from principalmapper.graphing.glue_edges import GlueEdgeChecker from principalmapper.graphing.iam_edges import IAMEdgeChecker from principalmapper.graphing.lambda_edges import LambdaEdgeChecker @@ -44,6 +45,7 @@ 'codebuild': CodeBuildEdgeChecker, 'datapipeline': DataPipelineEdgeChecker, 'ec2': EC2EdgeChecker, + 'ecs': ECSEdgeChecker, # TODO: need to verify ECS work 'glue': GlueEdgeChecker, 'iam': IAMEdgeChecker, 'lambda': LambdaEdgeChecker, diff --git a/principalmapper/graphing/glue_edges.py b/principalmapper/graphing/glue_edges.py index 89fbb78..5bc1b68 100644 --- a/principalmapper/graphing/glue_edges.py +++ b/principalmapper/graphing/glue_edges.py @@ -142,7 +142,7 @@ def generate_edges_locally(nodes: List[Node], scps: Optional[List[List[dict]]] = ) if update_ep_auth: if update_ep_needs_mfa: - reason = f'can use the Glue resource {target_endpoint} to access (needs MFA)' + reason = f'(requires MFA) can use the Glue resource {target_endpoint} to access' else: reason = f'can use the Glue resource {target_endpoint} to access' results.append(Edge( @@ -173,7 +173,7 @@ def generate_edges_locally(nodes: List[Node], scps: Optional[List[List[dict]]] = if create_ep_auth: if passrole_needs_mfa or create_ep_needs_mfa: - reason = 'can call glue:CreateDevEndpoint to access (needs MFA)' + reason = '(requires MFA) can call glue:CreateDevEndpoint to access' else: reason = 'can call glue:CreateDevEndpoint to access' results.append(Edge( diff --git a/principalmapper/graphing/ssm_edges.py b/principalmapper/graphing/ssm_edges.py index c8e5b7f..1a26572 100644 --- a/principalmapper/graphing/ssm_edges.py +++ b/principalmapper/graphing/ssm_edges.py @@ -96,7 +96,7 @@ def generate_edges_locally(nodes: List[Node], scps: Optional[List[List[dict]]] = if cmd_auth_res: reason = 'can call ssm:SendCommand to access an EC2 instance with access to' if mfa_res_1: - reason = '(Requires MFA) ' + reason + reason = '(requires MFA) ' + reason result.append(Edge(node_source, node_destination, reason, 'SSM')) sesh_auth_res, mfa_res_2 = query_interface.local_check_authorization_handling_mfa( @@ -109,7 +109,7 @@ def generate_edges_locally(nodes: List[Node], scps: Optional[List[List[dict]]] = if sesh_auth_res: reason = 'can call ssm:StartSession to access an EC2 instance with access to' if mfa_res_2: - reason = '(Requires MFA) ' + reason + reason = '(requires MFA) ' + reason result.append(Edge(node_source, node_destination, reason, 'SSM')) return result From 16075e45f8820e9d2341eea514e239034f9bf877 Mon Sep 17 00:00:00 2001 From: Erik Steringer Date: Thu, 3 Feb 2022 14:06:18 -0800 Subject: [PATCH 15/21] enable windows for test cases (cherry picked from commit a5800670bfcab4489b588181177afb4b1822ed21) --- .github/workflows/test_PRs.yml | 43 ++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/test_PRs.yml diff --git a/.github/workflows/test_PRs.yml b/.github/workflows/test_PRs.yml new file mode 100644 index 0000000..94ed1d8 --- /dev/null +++ b/.github/workflows/test_PRs.yml @@ -0,0 +1,43 @@ +# This workflow is designed to run through the process of installing, building, and executing +# basic PMapper unittests against PMapper's supported versions when there's a new PR aiming +# at the "master" branch + +name: "Test Against Pythons" + +on: + pull_request: + branches: [ master ] + workflow_dispatch: +permissions: + actions: read + issues: write + contents: read + discussions: write + +jobs: + build_and_test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: ["ubuntu-latest", "windows-latest"] + python-version: ["3.6"] #, "3.7", "3.8", "3.9", "3.10"] + steps: + - name: "Grab Code" + uses: actions/checkout@v2 + + - name: "Install Python" + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + + - name: "Install PMapper" + run: | + cd $GITHUB_WORKSPACE + pip install . + pip show principalmapper + + - name: "Run Test Cases" + run: | + cd $GITHUB_WORKSPACE + python -m unittest -v tests/test* From 32c795e0c5b917dfe709d667d6fdb139525350f1 Mon Sep 17 00:00:00 2001 From: Erik Steringer Date: Thu, 3 Feb 2022 14:16:10 -0800 Subject: [PATCH 16/21] attempt a fix on windows test cases (cherry picked from commit 6e7d9370aff5febac83cb26922b565547982d08e) --- .github/workflows/test_PRs.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test_PRs.yml b/.github/workflows/test_PRs.yml index 94ed1d8..d60c900 100644 --- a/.github/workflows/test_PRs.yml +++ b/.github/workflows/test_PRs.yml @@ -33,11 +33,9 @@ jobs: - name: "Install PMapper" run: | - cd $GITHUB_WORKSPACE - pip install . + cd ${{ github.workspace }} ; pip install . pip show principalmapper - name: "Run Test Cases" run: | - cd $GITHUB_WORKSPACE - python -m unittest -v tests/test* + cd ${{ github.workspace }} ; python -m unittest -v tests/test* From cf4522adc8ce90e400502ab6b1739efdea9d2db8 Mon Sep 17 00:00:00 2001 From: Erik Steringer Date: Thu, 3 Feb 2022 14:43:30 -0800 Subject: [PATCH 17/21] another fix attempt: use bash shell to get glob expansion (cherry picked from commit 5ebb0c7ddef5a3473ee87956a386985e17d06b08) --- .github/workflows/test_PRs.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/test_PRs.yml b/.github/workflows/test_PRs.yml index d60c900..c97a4ee 100644 --- a/.github/workflows/test_PRs.yml +++ b/.github/workflows/test_PRs.yml @@ -32,10 +32,12 @@ jobs: cache: 'pip' - name: "Install PMapper" + shell: bash run: | cd ${{ github.workspace }} ; pip install . pip show principalmapper - name: "Run Test Cases" + shell: bash run: | cd ${{ github.workspace }} ; python -m unittest -v tests/test* From 08946a1fb10e84dc92316e3b99bfe6a018f44211 Mon Sep 17 00:00:00 2001 From: Erik Steringer Date: Thu, 3 Feb 2022 14:48:10 -0800 Subject: [PATCH 18/21] another fix attempt: set the working-directory (cherry picked from commit 9cf9b1fc864d2df9561d7250b46f128965e060fd) --- .github/workflows/test_PRs.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test_PRs.yml b/.github/workflows/test_PRs.yml index c97a4ee..caefc0b 100644 --- a/.github/workflows/test_PRs.yml +++ b/.github/workflows/test_PRs.yml @@ -33,11 +33,13 @@ jobs: - name: "Install PMapper" shell: bash + working-directory: ${{ github.workspace }} run: | - cd ${{ github.workspace }} ; pip install . + pip install . pip show principalmapper - name: "Run Test Cases" shell: bash + working-directory: ${{ github.workspace }} run: | - cd ${{ github.workspace }} ; python -m unittest -v tests/test* + python -m unittest -v tests/test* From a11f49da34073cfab7550177d6a428dfe9382eab Mon Sep 17 00:00:00 2001 From: Erik Steringer Date: Thu, 3 Feb 2022 15:14:57 -0800 Subject: [PATCH 19/21] enable MacOS checks, other python versions (cherry picked from commit f8918d0d5be32e9cc19571ca5b40cfdc78bc1586) --- .github/workflows/test_PRs.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test_PRs.yml b/.github/workflows/test_PRs.yml index caefc0b..d4dfcb0 100644 --- a/.github/workflows/test_PRs.yml +++ b/.github/workflows/test_PRs.yml @@ -19,8 +19,8 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: ["ubuntu-latest", "windows-latest"] - python-version: ["3.6"] #, "3.7", "3.8", "3.9", "3.10"] + os: ["ubuntu-latest", "windows-latest", "macos-latest"] + python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] steps: - name: "Grab Code" uses: actions/checkout@v2 From 1d969f08104b12375703a5e7e077cad9e533315a Mon Sep 17 00:00:00 2001 From: Erik Steringer Date: Thu, 3 Feb 2022 15:32:31 -0800 Subject: [PATCH 20/21] reduce to min/max python versions for test cases, avoid fail-fast, remove pip caching (cherry picked from commit 91d2e60102bdadf346d77b60d90ddaa4a678f037) --- .github/workflows/test_PRs.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test_PRs.yml b/.github/workflows/test_PRs.yml index d4dfcb0..a4753e4 100644 --- a/.github/workflows/test_PRs.yml +++ b/.github/workflows/test_PRs.yml @@ -18,9 +18,10 @@ jobs: build_and_test: runs-on: ${{ matrix.os }} strategy: + fail-fast: false matrix: os: ["ubuntu-latest", "windows-latest", "macos-latest"] - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] + python-version: ["3.6", "3.10"] steps: - name: "Grab Code" uses: actions/checkout@v2 @@ -29,7 +30,6 @@ jobs: uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - cache: 'pip' - name: "Install PMapper" shell: bash From 06f1dc1ec8ac4c3c9c775aabf7efce2652cac6f3 Mon Sep 17 00:00:00 2001 From: Erik Steringer Date: Tue, 12 Apr 2022 11:02:26 -0700 Subject: [PATCH 21/21] fix for #114, including a test case --- principalmapper/graphing/sts_edges.py | 31 +++++++++++++++++++-------- tests/build_test_graphs.py | 22 +++++++++++++++++-- tests/test_edge_identification.py | 6 ++++++ 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/principalmapper/graphing/sts_edges.py b/principalmapper/graphing/sts_edges.py index ce31632..40b72c1 100644 --- a/principalmapper/graphing/sts_edges.py +++ b/principalmapper/graphing/sts_edges.py @@ -25,6 +25,7 @@ from principalmapper.querying import query_interface from principalmapper.querying.local_policy_simulation import resource_policy_authorization, ResourcePolicyEvalResult, has_matching_statement from principalmapper.util import arns +from principalmapper.util.case_insensitive_dict import CaseInsensitiveDict logger = logging.getLogger(__name__) @@ -66,6 +67,7 @@ def generate_edges_locally(nodes: List[Node], scps: Optional[List[List[dict]]] = continue # Check against resource policy + rp_mfa_required = False sim_result = resource_policy_authorization( node_source, arns.get_account_id(node_source.arn), @@ -75,11 +77,22 @@ def generate_edges_locally(nodes: List[Node], scps: Optional[List[List[dict]]] = {}, ) - if sim_result == ResourcePolicyEvalResult.DENY_MATCH: - continue # Node was explicitly denied from assuming the role - - if sim_result == ResourcePolicyEvalResult.NO_MATCH: - continue # Resource policy must match for sts:AssumeRole, even in same-account scenarios + if sim_result == ResourcePolicyEvalResult.DENY_MATCH or sim_result == ResourcePolicyEvalResult.NO_MATCH: + sim_result = resource_policy_authorization( + node_source, + arns.get_account_id(node_source.arn), + node_destination.trust_policy, + 'sts:AssumeRole', + node_destination.arn, + { + 'aws:MultiFactorAuthAge': '1', + 'aws:MultiFactorAuthPresent': 'true' + } + ) + if sim_result == ResourcePolicyEvalResult.DENY_MATCH or sim_result == ResourcePolicyEvalResult.NO_MATCH: + continue + else: + rp_mfa_required = True # Resource Policy auth check passed when MFA elements set assume_auth, need_mfa = query_interface.local_check_authorization_handling_mfa( node_source, 'sts:AssumeRole', node_destination.arn, {}, service_control_policy_groups=scps @@ -89,21 +102,21 @@ def generate_edges_locally(nodes: List[Node], scps: Optional[List[List[dict]]] = 'Deny', 'sts:AssumeRole', node_destination.arn, - {}, + CaseInsensitiveDict({}), ) policy_denies_mfa = has_matching_statement( node_source, 'Deny', 'sts:AssumeRole', node_destination.arn, - { + CaseInsensitiveDict({ 'aws:MultiFactorAuthAge': '1', 'aws:MultiFactorAuthPresent': 'true' - }, + }), ) if assume_auth: - if need_mfa: + if need_mfa or rp_mfa_required: reason = '(requires MFA) can access via sts:AssumeRole' else: reason = 'can access via sts:AssumeRole' diff --git a/tests/build_test_graphs.py b/tests/build_test_graphs.py index fffb74f..dba7f86 100644 --- a/tests/build_test_graphs.py +++ b/tests/build_test_graphs.py @@ -24,7 +24,7 @@ def build_empty_graph() -> Graph: """Constructs and returns a Graph object with no nodes, edges, policies, or groups""" - return Graph([], [], [], [], _get_default_metadata()) + return Graph([], [], [], [], '000000000000', 'aws', _get_default_metadata()) def build_graph_with_one_admin() -> Graph: @@ -72,6 +72,24 @@ def build_playground_graph() -> Graph: nodes.append(Node(common_iam_prefix + 'role/s3_access_role', 'AIDA00000000000000003', [s3_full_access_policy], [], root_trusted_policy_doc, None, 0, False, False, None, False, None)) + # assumable role with s3 access and MFA required to assume + nodes.append(Node(common_iam_prefix + 'role/mfa_role_with_s3_access', 'AIDA0000000000000099', [s3_full_access_policy], [], + { + 'Version': '2012-10-17', + 'Statement': [ + { + 'Effect': 'Allow', + 'Principal': {'AWS': 'arn:aws:iam::000000000000:root'}, + 'Action': 'sts:AssumeRole', + 'Condition': { + 'Bool': { + 'aws:MultiFactorAuthPresent': 'true' + } + } + } + ] + }, None, 0, False, False, None, False, None)) + # second assumable role with s3 access with alternative trust policy nodes.append(Node(common_iam_prefix + 'role/s3_access_role_alt', 'AIDA00000000000000004', [s3_full_access_policy], [], alt_root_trusted_policy_doc, None, 0, False, False, None, False, None)) @@ -81,7 +99,7 @@ def build_playground_graph() -> Graph: other_acct_trusted_policy_doc, None, 0, False, False, None, False, None)) # jump user with access to sts:AssumeRole - nodes.append(Node(common_iam_prefix + 'user/jumpuser', 'AIDA00000000000000006', [jump_policy], [], None, None, 1, True, False, None, False, None)) + nodes.append(Node(common_iam_prefix + 'user/jumpuser', 'AIDA00000000000000006', [jump_policy], [], None, None, 1, True, False, None, True, None)) # user with S3 access, path in user's ARN nodes.append(Node(common_iam_prefix + 'user/somepath/some_other_jumpuser', 'AIDA00000000000000007', [jump_policy], diff --git a/tests/test_edge_identification.py b/tests/test_edge_identification.py index bfe44bf..750ff63 100644 --- a/tests/test_edge_identification.py +++ b/tests/test_edge_identification.py @@ -47,3 +47,9 @@ def test_admin_access(self): self.assertTrue(is_connected(graph, admin_user_node, jump_user)) self.assertTrue(is_connected(graph, admin_user_node, nonassumable_role_node)) self.assertTrue(is_connected(graph, other_jump_user, other_assumable_role)) + + def test_mfa_assume_role(self): + graph = build_playground_graph() + source_node = graph.get_node_by_searchable_name('user/jumpuser') + mfa_target_node = graph.get_node_by_searchable_name('role/mfa_role_with_s3_access') + self.assertTrue(is_connected(graph, source_node, mfa_target_node))