From 6139b13e4a91cb17a5817e7127de9f3bd939eeda Mon Sep 17 00:00:00 2001 From: ncc-erik-steringer Date: Fri, 28 May 2021 15:52:05 -0700 Subject: [PATCH] v1.1.2 (#85) * Added Secrets Manager support. Cleaned up resource-policy handling from command line. * Adding minimum permissions policy doc. * implemented edge checks related to SageMaker: CreateTrainingJob and CreateProcessingJob * added codebuild support * added ec2 auto scaling support * update findings writing Co-authored-by: Erik Steringer --- principalmapper/__init__.py | 2 +- principalmapper/analysis/find_risks.py | 10 +- principalmapper/graphing/autoscaling_edges.py | 213 +++++++++++++ principalmapper/graphing/codebuild_edges.py | 288 ++++++++++++++++++ principalmapper/graphing/ec2_edges.py | 4 +- .../graphing/edge_identification.py | 4 + principalmapper/graphing/gathering.py | 67 +++- principalmapper/graphing/sagemaker_edges.py | 52 +++- principalmapper/querying/argquery_cli.py | 22 +- principalmapper/querying/query_cli.py | 19 +- principalmapper/querying/query_utils.py | 6 +- .../visualizing/graphviz_writer.py | 1 + required-permissions.json | 32 ++ setup.py | 2 + 14 files changed, 689 insertions(+), 33 deletions(-) create mode 100644 principalmapper/graphing/autoscaling_edges.py create mode 100644 principalmapper/graphing/codebuild_edges.py create mode 100644 required-permissions.json diff --git a/principalmapper/__init__.py b/principalmapper/__init__.py index f04cb97..c03c06b 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.1' +__version__ = '1.1.2' diff --git a/principalmapper/analysis/find_risks.py b/principalmapper/analysis/find_risks.py index 8598c62..afcaf6d 100644 --- a/principalmapper/analysis/find_risks.py +++ b/principalmapper/analysis/find_risks.py @@ -1,5 +1,6 @@ """Python code for identifying risks using a Graph generated by Principal Mapper. The findings are tracked using dictionary objects with the format: + { "title": , "severity": "Low|Medium|High", @@ -94,6 +95,10 @@ def gen_privesc_findings(graph: Graph) -> List[Finding]: description_preamble = 'In AWS, IAM Principals such as IAM Users or IAM Roles have their permissions defined ' \ 'using IAM Policies. These policies describe different actions, resources, and ' \ 'conditions where the principal can make a given API call to a service.\n\n' \ + 'Administrative principals can call any action with any resource, as in the ' \ + 'AdministratorAccess AWS-managed policy. However, some permissions may allow another ' \ + 'non-administrative principal to gain access to an administrative principal. ' \ + 'This represents a privilege escalation risk.\n\n' \ 'The following principals could escalate privileges:\n\n' description_body = '' @@ -109,7 +114,7 @@ def gen_privesc_findings(graph: Graph) -> List[Finding]: 'IAM {} Can Escalate Privileges'.format('Principals' if len(node_path_list) > 1 else 'Principal'), 'High', 'A lower-privilege IAM User or Role is able to gain administrative privileges. This could lead to the ' - 'lower-privilege principal being used to compromise the account\'s resources.', + 'lower-privilege principal being used to compromise the account and its resources.', description_preamble + description_body, 'Review the IAM Policies that are applicable to the affected IAM User(s) or Role(s). Either reduce the ' 'permissions of the administrative principal(s), or reduce the permissions of the principal(s) that can ' @@ -419,7 +424,8 @@ def gen_admin_users_without_mfa_finding(graph: Graph) -> List[Finding]: 'create IAM Policies that impose extra restrictions on the permissions of IAM Users ' \ 'depending on whether or not they have authenticated with MFA when using the AWS API. ' \ 'Any IAM User with administrative privileges should be configured to have an MFA ' \ - 'device. The following IAM Users with administrative privileges do not have an MFA ' \ + 'device. \n\n' \ + 'The following IAM Users with administrative privileges do not have an MFA ' \ 'device configured:' \ '\n' \ '\n' diff --git a/principalmapper/graphing/autoscaling_edges.py b/principalmapper/graphing/autoscaling_edges.py new file mode 100644 index 0000000..6562b70 --- /dev/null +++ b/principalmapper/graphing/autoscaling_edges.py @@ -0,0 +1,213 @@ +"""Code to identify if a principal in an AWS account can use access to EC2 Auto Scaling 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 AutoScalingEdgeChecker(EdgeChecker): + """Class for identifying if EC2 Auto Scaling 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) -> List[Edge]: + """Fulfills expected method return_edges.""" + + logger.info('Generating Edges based on EC2 Auto Scaling.') + + # 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) + for region in as_regions: + autoscaling_clients.append(self.session.create_client('autoscaling', region_name=region)) + + launch_configs = [] + for as_client in autoscaling_clients: + logger.debug('Looking at region {}'.format(as_client.meta.region_name)) + try: + lc_paginator = as_client.get_paginator('describe_launch_configurations') + for page in lc_paginator.paginate(): + if 'LaunchConfigurations' in page: + for launch_config in page['LaunchConfigurations']: + if 'IamInstanceProfile' in launch_config and launch_config['IamInstanceProfile']: + launch_configs.append({ + 'lc_arn': launch_config['LaunchConfigurationARN'], + 'lc_iip': launch_config['IamInstanceProfile'] + }) + + 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)) + + result = generate_edges_locally(nodes, scps, launch_configs) + + 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, launch_configs: Optional[List[dict]] = None) -> List[Edge]: + """Generates and returns Edge objects related to EC2 AutoScaling. + + It is possible to use this method if you are operating offline (infra-as-code). The `launch_configs` param + should be a list of dictionary objects with the following expected structure: + + ~~~ + { + 'lc_arn': , + 'lc_iip': + } + ~~~ + + All elements are required, but if there is no instance profile then set the field to None. + """ + + result = [] + + # iterate through nodes, setting up the map as well as identifying if the service role is available + role_lc_map = {} + service_role_available = False + for node in nodes: + # this should catch the normal service role + custom ones with the suffix + if ':role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling' in node.arn: + service_role_available = True + + if node.instance_profile is not None: + for launch_config in launch_configs: + if launch_config['lc_iip'] in node.instance_profile: + if node in role_lc_map: + role_lc_map[node].append(launch_config['lc_arn']) + else: + role_lc_map[node] = [launch_config['lc_arn']] + + for node_destination in nodes: + # check if destination is a user, skip if so + if ':role/' not in node_destination.arn: + continue + + # check that the destination role can be assumed by EC2 + sim_result = resource_policy_authorization( + 'ec2.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 # EC2 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 + + csr_mfa = False # stash for later ref + if not service_role_available: + create_service_role_auth, csr_mfa = query_interface.local_check_authorization_handling_mfa( + node_source, + 'iam:CreateServiceLinkedRole', + '*', + { + 'iam:AWSServiceName': 'autoscaling.amazonaws.com' + }, + service_control_policy_groups=scps + ) + if not create_service_role_auth: + continue # service role can't be used if it doesn't exist or be created + + create_auto_scaling_group_auth, casg_mfa = query_interface.local_check_authorization_handling_mfa( + node_source, + 'autoscaling:CreateAutoScalingGroup', + '*', + {}, + service_control_policy_groups=scps + ) + if not create_auto_scaling_group_auth: + continue # can't create an auto-scaling group -> move along + + if node_destination in role_lc_map: + if service_role_available: + reason = 'can use the EC2 Auto Scaling service role and an existing Launch Configuration to access' + else: + reason = 'can create the EC2 Auto Scaling service role and an existing Launch Configuration to access' + + if csr_mfa or casg_mfa: + reason = '(MFA Required) ' + reason + + result.append(Edge( + node_source, + node_destination, + reason, + 'EC2 Auto Scaling' + )) + + create_launch_config_auth, clc_mfa = query_interface.local_check_authorization_handling_mfa( + node_source, + 'autoscaling:CreateLaunchConfiguration', + '*', + {}, + service_control_policy_groups=scps + ) + + if not create_launch_config_auth: + continue # we're done here + + pass_role_auth, pr_mfa = query_interface.local_check_authorization_handling_mfa( + node_source, + 'iam:PassRole', + node_destination.arn, + { + 'iam:PassedToService': 'ec2.amazonaws.com' + }, + service_control_policy_groups=scps + ) + + if pass_role_auth: + if service_role_available: + reason = 'can use the EC2 Auto Scaling service role and create a launch configuration to access' + else: + reason = 'can create the EC2 Auto Scaling service role and create a launch configuration to access' + if clc_mfa or pr_mfa: + reason = '(MFA Required) ' + reason + + result.append(Edge( + node_source, + node_destination, + reason, + 'EC2 Auto Scaling' + )) + + return result diff --git a/principalmapper/graphing/codebuild_edges.py b/principalmapper/graphing/codebuild_edges.py new file mode 100644 index 0000000..4fd9355 --- /dev/null +++ b/principalmapper/graphing/codebuild_edges.py @@ -0,0 +1,288 @@ +"""Code to identify if a principal in an AWS account can use access to AWS CodeBuild 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 CodeBuildEdgeChecker(EdgeChecker): + """Class for identifying if CodeBuild 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) -> List[Edge]: + """Fulfills expected method return_edges.""" + + logger.info('Generating Edges based on CodeBuild.') + + # Gather projects information for each region + + 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) + for region in cf_regions: + codebuild_clients.append(self.session.create_client('codebuild', region_name=region)) + + codebuild_projects = [] + for cb_client in codebuild_clients: + logger.debug('Looking at region {}'.format(cb_client.meta.region_name)) + region_project_list_list = [] + try: + # list the projects first, 50 at a time + paginator = cb_client.get_paginator('list_projects') + for page in paginator.paginate(PaginationConfig={'MaxItems': 50}): + if 'projects' in page and len(page['projects']) > 0: + region_project_list_list.append(page['projects']) + + for region_project_list in region_project_list_list: + batch_project_data = cb_client.batch_get_projects(names=region_project_list) # no pagination + if 'projects' in batch_project_data: + for project_data in batch_project_data['projects']: + if 'serviceRole' in project_data: + codebuild_projects.append({ + 'project_arn': project_data['arn'], + 'project_role': project_data['serviceRole'], + 'project_tags': project_data['tags'] + }) + + 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(cb_client.meta.region_name)) + logger.debug('Exception details: {}'.format(ex)) + + result = generate_edges_locally(nodes, scps, codebuild_projects) + + for edge in result: + logger.info("Found new edge: {}".format(edge.describe_edge())) + + return result + + +def _gen_resource_tag_conditions(tag_list: List[dict]): + condition_result = { + # 'aws:TagKeys': [] + } + for tag in tag_list: + condition_result.update({ + 'aws:ResourceTag/{}'.format(tag['Key']): tag['Value'] + }) + # TODO: make sure we're handling RequestTag and TagKeys correctly + # condition_result.update({ + # 'aws:RequestTag/{}'.format(tag['Key']): tag['Value'] + # }) + # condition_result['aws:TagKeys'].append(tag['Key']) + return condition_result + + +def generate_edges_locally(nodes: List[Node], scps: Optional[List[List[dict]]] = None, codebuild_projects: Optional[List[dict]] = None) -> List[Edge]: + """Generates and returns Edge objects related to AWS CodeBuild. + + It is possible to use this method if you are operating offline (infra-as-code). The `codebuild_projects` param + should be a list of dictionary objects with the following expected structure: + + ``` + { + 'project_arn': , + 'project_role': + 'project_tags': , 'Value': }]> + } + ``` + + All elements are required, tags must point to an empty list if there are no tags attached to the project + """ + + result = [] + + # we wanna create a role -> [{proj_arn: <>, proj_tags: <>}] map to make eventual lookups faster + if codebuild_projects is None: + codebuild_map = {} + else: + codebuild_map = {} # type: Dict[str, List[dict]] + for project in codebuild_projects: + if project['project_role'] not in codebuild_map: + codebuild_map[project['project_role']] = [{'proj_arn': project['project_arn'], 'proj_tags': project['project_tags']}] + else: + codebuild_map[project['project_role']].append({'proj_arn': project['project_arn'], 'proj_tags': project['project_tags']}) + + for node_destination in nodes: + # check if destination is a user, skip if so + if ':role/' not in node_destination.arn: + continue + + # check that the destination role can be assumed by CodeBuild + sim_result = resource_policy_authorization( + 'codebuild.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 # CodeBuild 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 projects + if node_destination.arn in codebuild_map: + projects = codebuild_map[node_destination.arn] + for project in projects: + startproj_auth, startproj_mfa = query_interface.local_check_authorization_handling_mfa( + node_source, + 'codebuild:StartBuild', + project['proj_arn'], + _gen_resource_tag_conditions(project['proj_tags']), + service_control_policy_groups=scps + ) + if startproj_auth: + result.append(Edge( + node_source, + node_destination, + '(MFA Required) can use CodeBuild with an existing project to access' if startproj_mfa else 'can use CodeBuild with an existing project to access', + 'CodeBuild' + )) + break # break out of iterating through projects + + batchstartproj_auth, batchstartproj_mfa = query_interface.local_check_authorization_handling_mfa( + node_source, + 'codebuild:StartBuildBatch', + project['proj_arn'], + _gen_resource_tag_conditions(project['proj_tags']), + service_control_policy_groups=scps + ) + if batchstartproj_auth: + result.append(Edge( + node_source, + node_destination, + '(MFA Required) can use CodeBuild with an existing project to access' if startproj_mfa else 'can use CodeBuild with an existing project to access', + 'CodeBuild' + )) + break # break out of iterating through projects + + # check if source can create/update a project, pass this role, then start a build + condition_keys = {'iam:PassedToService': 'codebuild.amazonaws.com'} + pass_role_auth, pass_role_mfa = query_interface.local_check_authorization_handling_mfa( + node_source, + 'iam:PassRole', + node_destination.arn, + condition_keys, + service_control_policy_groups=scps + ) + + if not pass_role_auth: + continue # if we can't pass this role, then we're done + + # check if the source can create a project and start a build + create_proj_auth, create_proj_mfa = query_interface.local_check_authorization_handling_mfa( + node_source, + 'codebuild:CreateProject', + '*', + {}, + service_control_policy_groups=scps + ) + if create_proj_auth: + startproj_auth, startproj_mfa = query_interface.local_check_authorization_handling_mfa( + node_source, + 'codebuild:StartBuild', + '*', + {}, + service_control_policy_groups=scps + ) + if startproj_auth: + result.append(Edge( + node_source, + node_destination, + '(MFA Required) can create a project in CodeBuild to access' if create_proj_mfa or pass_role_mfa else 'can create a project in CodeBuild to access', + 'CodeBuild' + )) + else: + batchstartproj_auth, batchstartproj_mfa = query_interface.local_check_authorization_handling_mfa( + node_source, + 'codebuild:StartBuildBatch', + '*', + {}, + service_control_policy_groups=scps + ) + if batchstartproj_auth: + result.append(Edge( + node_source, + node_destination, + '(MFA Required) can create a project in CodeBuild to access' if create_proj_mfa or pass_role_mfa else 'can create a project in CodeBuild to access', + 'CodeBuild' + )) + + # check if the source can update a project and start a build + for project in codebuild_projects: + update_proj_auth, update_proj_mfa = query_interface.local_check_authorization_handling_mfa( + node_source, + 'codebuild:UpdateProject', + project['project_arn'], + _gen_resource_tag_conditions(project['project_tags']), + service_control_policy_groups=scps + ) + if update_proj_auth: + startproj_auth, startproj_mfa = query_interface.local_check_authorization_handling_mfa( + node_source, + 'codebuild:StartBuild', + project['project_arn'], + _gen_resource_tag_conditions(project['project_tags']), + service_control_policy_groups=scps + ) + if startproj_auth: + result.append(Edge( + node_source, + node_destination, + '(MFA Required) can update a project in CodeBuild to access' if create_proj_mfa or pass_role_mfa else 'can update a project in CodeBuild to access', + 'CodeBuild' + )) + break # just wanna find that there exists one updatable/usable project + else: + batchstartproj_auth, batchstartproj_mfa = query_interface.local_check_authorization_handling_mfa( + node_source, + 'codebuild:StartBuildBatch', + project['project_arn'], + _gen_resource_tag_conditions(project['project_tags']), + service_control_policy_groups=scps + ) + if batchstartproj_auth: + result.append(Edge( + node_source, + node_destination, + '(MFA Required) can update a project in CodeBuild to access' if create_proj_mfa or pass_role_mfa else 'can update a project in CodeBuild to access', + 'CodeBuild' + )) + break # just wanna find that there exists one updatable/usable project + + return result diff --git a/principalmapper/graphing/ec2_edges.py b/principalmapper/graphing/ec2_edges.py index c0787d7..7e06a8f 100644 --- a/principalmapper/graphing/ec2_edges.py +++ b/principalmapper/graphing/ec2_edges.py @@ -128,7 +128,7 @@ def generate_edges_locally(nodes: List[Node], scps: Optional[List[List[dict]]] = mfa_needed = True if create_instance_res: - if iprofile is not '*': + if iprofile != '*': reason = 'can use EC2 to run an instance with an existing instance profile to access' else: reason = 'can use EC2 to run an instance with a newly created instance profile to access' @@ -164,7 +164,7 @@ def generate_edges_locally(nodes: List[Node], scps: Optional[List[List[dict]]] = service_control_policy_groups=scps ) - if iprofile is not '*': + if iprofile != '*': reason = 'can use EC2 to run an instance and then associate an existing instance profile to ' \ 'access' else: diff --git a/principalmapper/graphing/edge_identification.py b/principalmapper/graphing/edge_identification.py index 412d275..ff0e9a6 100644 --- a/principalmapper/graphing/edge_identification.py +++ b/principalmapper/graphing/edge_identification.py @@ -21,7 +21,9 @@ import botocore.session from principalmapper.common import Edge, Node +from principalmapper.graphing.autoscaling_edges import AutoScalingEdgeChecker from principalmapper.graphing.cloudformation_edges import CloudFormationEdgeChecker +from principalmapper.graphing.codebuild_edges import CodeBuildEdgeChecker from principalmapper.graphing.ec2_edges import EC2EdgeChecker from principalmapper.graphing.iam_edges import IAMEdgeChecker from principalmapper.graphing.lambda_edges import LambdaEdgeChecker @@ -35,7 +37,9 @@ # Externally referable dictionary with all the supported edge-checking types checker_map = { + 'autoscaling': AutoScalingEdgeChecker, 'cloudformation': CloudFormationEdgeChecker, + 'codebuild': CodeBuildEdgeChecker, 'ec2': EC2EdgeChecker, 'iam': IAMEdgeChecker, 'lambda': LambdaEdgeChecker, diff --git a/principalmapper/graphing/gathering.py b/principalmapper/graphing/gathering.py index 17587e8..18b008d 100644 --- a/principalmapper/graphing/gathering.py +++ b/principalmapper/graphing/gathering.py @@ -73,11 +73,12 @@ def create_graph(session: botocore.session.Session, service_list: list, region_a scps ) - # Pull S3, SNS, SQS, and KMS resource policies + # Pull S3, SNS, SQS, KMS, and Secrets Manager resource policies policies_result.extend(get_s3_bucket_policies(session)) policies_result.extend(get_sns_topic_policies(session, region_allow_list, region_deny_list)) policies_result.extend(get_sqs_queue_policies(session, caller_identity['Account'], region_allow_list, region_deny_list)) policies_result.extend(get_kms_key_policies(session, region_allow_list, region_deny_list)) + policies_result.extend(get_secrets_manager_policies(session, region_allow_list, region_deny_list)) return Graph(nodes_result, edges_result, policies_result, groups_result, metadata) @@ -324,8 +325,8 @@ def get_kms_key_policies(session: botocore.session.Session, region_allow_list: O def get_sns_topic_policies(session: botocore.session.Session, region_allow_list: Optional[List[str]] = None, region_deny_list: Optional[List[str]] = None) -> List[Policy]: - """Using a botocore Session object, return a list of Policy objects representing the key policies of each - KMS key in this account. + """Using a botocore Session object, return a list of Policy objects representing the topic policies of each + SNS topic in this account. The region allow/deny lists are mutually-exclusive (i.e. at least one of which has the value None) lists of allowed/denied regions to pull data from. @@ -360,8 +361,8 @@ 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) -> List[Policy]: - """Using a botocore Session object, return a list of Policy objects representing the key policies of each - KMS key in this account. + """Using a botocore Session object, return a list of Policy objects representing the queue policies of each + SQS queue in this account. The region allow/deny lists are mutually-exclusive (i.e. at least one of which has the value None) lists of allowed/denied regions to pull data from. @@ -409,6 +410,62 @@ def get_sqs_queue_policies(session: botocore.session.Session, account_id: str, r 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) -> List[Policy]: + """Using a botocore Session object, return a list of Policy objects representing the resource policies + of the secrets in AWS Secrets Manager. + + The region allow/deny lists are mutually-exclusive (i.e. at least one of which has the value None) lists of + allowed/denied regions to pull data from. + """ + result = [] + + # 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): + try: + # Grab the ARNs of the secrets in this region + secret_arns = [] + smclient = session.create_client('secretsmanager', region_name=sm_region) + list_secrets_paginator = smclient.get_paginator('list_secrets') + for page in list_secrets_paginator.paginate(): + if 'SecretList' in page: + for entry in page['SecretList']: + if 'PrimaryRegion' in entry and entry['PrimaryRegion'] != sm_region: + continue # skip things we're supposed to find in other regions + secret_arns.append(entry['ARN']) + + # Grab resource policies for each secret + for secret_arn in secret_arns: + sm_response = smclient.get_resource_policy(SecretId=secret_arn) + + # verify that it is in the response and not None/empty + if 'ResourcePolicy' in sm_response and sm_response['ResourcePolicy']: + sm_policy_doc = json.loads(sm_response['ResourcePolicy']) + result.append(Policy( + secret_arn, + sm_response['Name'], + sm_policy_doc + )) + logger.info('Storing the resource policy for secret {}'.format(secret_arn)) + else: + result.append(Policy( + secret_arn, + sm_response['Name'], + { + "Statement": [], + "Version": "2012-10-17" + } + )) + logger.info('Secret {} does not have a resource policy, inserting a "stub" policy instead'.format(secret_arn)) + + except botocore.exceptions.ClientError as ex: + logger.info('Unable to search Secrets Manager in region {} for secrets. The region may be disabled, or ' + 'the current principal may not be authorized to access the service. ' + 'Continuing.'.format(sm_region)) + logger.debug('Exception was: {}'.format(ex)) + + return result + + def get_unfilled_nodes(iamclient) -> List[Node]: """Using an IAM.Client object, return a list of Node object for each IAM user and role in an account. diff --git a/principalmapper/graphing/sagemaker_edges.py b/principalmapper/graphing/sagemaker_edges.py index 85db26a..eceb3cc 100644 --- a/principalmapper/graphing/sagemaker_edges.py +++ b/principalmapper/graphing/sagemaker_edges.py @@ -31,7 +31,7 @@ class SageMakerEdgeChecker(EdgeChecker): """Class for identifying if Amazon SageMaker can be used by IAM principals to access other principals. - TODO: add checks for CreateDomain, CreateProcessingJob, CreateTrainingJob + TODO: add checks for CreateDomain and related operations """ def return_edges(self, nodes: List[Node], region_allow_list: Optional[List[str]] = None, @@ -78,7 +78,7 @@ def generate_edges_locally(nodes: List[Node], scps: Optional[List[List[dict]]] = mfa_needed = False conditions = {'iam:PassedToService': 'sagemaker.amazonaws.com'} - pass_role_auth, needs_mfa = query_interface.local_check_authorization_handling_mfa( + pass_role_auth, pass_needs_mfa = query_interface.local_check_authorization_handling_mfa( node_source, 'iam:PassRole', node_destination.arn, @@ -88,9 +88,6 @@ def generate_edges_locally(nodes: List[Node], scps: Optional[List[List[dict]]] = if not pass_role_auth: continue # source node is not authorized to pass the role - if needs_mfa: - mfa_needed = True - create_notebook_auth, needs_mfa = query_interface.local_check_authorization_handling_mfa( node_source, 'sagemaker:CreateNotebookInstance', @@ -99,18 +96,45 @@ def generate_edges_locally(nodes: List[Node], scps: Optional[List[List[dict]]] = service_control_policy_groups=scps ) - if not create_notebook_auth: - continue # source node is not authorized to launch the sagemaker notebook + if create_notebook_auth: + new_edge = Edge( + node_source, + node_destination, + '(MFA required) can use SageMaker to launch a notebook and access' if pass_needs_mfa or needs_mfa else 'can use SageMaker to launch a notebook and access', + 'SageMaker' + ) + result.append(new_edge) - if needs_mfa: - mfa_needed = True + create_training_auth, needs_mfa = query_interface.local_check_authorization_handling_mfa( + node_source, + 'sagemaker:CreateTrainingJob', + '*', + {}, + service_control_policy_groups=scps + ) - new_edge = Edge( + if create_training_auth: + result.append(Edge( + node_source, + node_destination, + '(MFA required) can use SageMaker to create a training job and access' if pass_needs_mfa or needs_mfa else 'can use SageMaker to create a training job and access', + 'SageMaker' + )) + + create_processing_auth, needs_mfa = query_interface.local_check_authorization_handling_mfa( node_source, - node_destination, - '(MFA required) can use SageMaker to launch a notebook and access' if mfa_needed else 'can use SageMaker to launch a notebook and access', - 'SageMaker' + 'sagemaker:CreateProcessingJob', + '*', + {}, + service_control_policy_groups=scps ) - result.append(new_edge) + + if create_processing_auth: + result.append(Edge( + node_source, + node_destination, + '(MFA required) can use SageMaker to create a processing job and access' if pass_needs_mfa or needs_mfa else 'can use SageMaker to create a processing job and access', + 'SageMaker' + )) return result diff --git a/principalmapper/querying/argquery_cli.py b/principalmapper/querying/argquery_cli.py index 5a81b65..216661a 100644 --- a/principalmapper/querying/argquery_cli.py +++ b/principalmapper/querying/argquery_cli.py @@ -20,10 +20,10 @@ import json import logging -from principalmapper.common import OrganizationTree +from principalmapper.common import OrganizationTree, Policy from principalmapper.graphing import graph_actions from principalmapper.querying import query_utils, query_actions, query_orgs -from principalmapper.util import botocore_tools +from principalmapper.util import botocore_tools, arns from principalmapper.util.storage import get_storage_root logger = logging.getLogger(__name__) @@ -119,12 +119,26 @@ def process_arguments(parsed_args: Namespace): conditions.update({key: value}) if parsed_args.with_resource_policy: - resource_policy = query_utils.pull_cached_resource_policy_by_arn(graph.policies, parsed_args.resource) + resource_policy = query_utils.pull_cached_resource_policy_by_arn( + graph.policies, + parsed_args.resource + ) elif parsed_args.resource_policy_text: resource_policy = json.loads(parsed_args.resource_policy_text) else: resource_policy = None + resource_owner = parsed_args.resource_owner + if resource_policy is not None: + if parsed_args.resource_owner is None: + if arns.get_service(resource_policy.arn) == 's3': + raise ValueError('Must supply resource owner (--resource-owner) when including S3 bucket policies ' + 'in a query') + else: + resource_owner = arns.get_account_id(resource_policy.arn) + if isinstance(resource_policy, Policy): + resource_policy = resource_policy.policy_doc + if parsed_args.scps: if 'org-id' in graph.metadata and 'org-path' in graph.metadata: org_tree_path = os.path.join(get_storage_root(), graph.metadata['org-id']) @@ -138,7 +152,7 @@ def process_arguments(parsed_args: Namespace): query_actions.argquery(graph, parsed_args.principal, parsed_args.action, parsed_args.resource, conditions, parsed_args.preset, parsed_args.skip_admin, resource_policy, - parsed_args.resource_owner, parsed_args.include_unauthorized, parsed_args.session_policy, + resource_owner, parsed_args.include_unauthorized, parsed_args.session_policy, scps) return 0 diff --git a/principalmapper/querying/query_cli.py b/principalmapper/querying/query_cli.py index 15f9ce7..8902bf6 100644 --- a/principalmapper/querying/query_cli.py +++ b/principalmapper/querying/query_cli.py @@ -21,10 +21,10 @@ import json import logging -from principalmapper.common import OrganizationTree +from principalmapper.common import OrganizationTree, Policy from principalmapper.graphing import graph_actions from principalmapper.querying import query_utils, query_actions, query_orgs -from principalmapper.util import botocore_tools +from principalmapper.util import botocore_tools, arns from principalmapper.util.storage import get_storage_root logger = logging.getLogger(__name__) @@ -88,13 +88,26 @@ def process_arguments(parsed_args: Namespace): logger.debug('Querying against graph {}'.format(graph.metadata['account_id'])) if parsed_args.with_resource_policy: - resource_policy = query_utils.pull_cached_resource_policy_by_arn(graph.policies, arn=None, query=parsed_args.query) + resource_policy = query_utils.pull_cached_resource_policy_by_arn( + graph.policies, + arn=None, + query=parsed_args.query + ) elif parsed_args.resource_policy_text: resource_policy = json.loads(parsed_args.resource_policy_text) else: resource_policy = None resource_owner = parsed_args.resource_owner + if resource_policy is not None: + if resource_owner is None: + if arns.get_service(resource_policy.arn) == 's3': + raise ValueError('Must supply resource owner (--resource-owner) when including S3 bucket policies ' + 'in a query') + else: + resource_owner = arns.get_account_id(resource_policy.arn) + if isinstance(resource_policy, Policy): + resource_policy = resource_policy.policy_doc if parsed_args.scps: if 'org-id' in graph.metadata and 'org-path' in graph.metadata: diff --git a/principalmapper/querying/query_utils.py b/principalmapper/querying/query_utils.py index 67adc9d..d793232 100644 --- a/principalmapper/querying/query_utils.py +++ b/principalmapper/querying/query_utils.py @@ -80,7 +80,7 @@ def is_connected(graph: Graph, source: Node, destination: Node) -> bool: return False -def pull_cached_resource_policy_by_arn(policies: List[Policy], arn: Optional[str], query: str = None) -> dict: +def pull_cached_resource_policy_by_arn(policies: List[Policy], arn: Optional[str], query: str = None) -> Policy: """Function that pulls a resource policy that's cached on-disk. Raises ValueError if it is not able to be retrieved. @@ -111,12 +111,14 @@ def pull_cached_resource_policy_by_arn(policies: List[Policy], arn: Optional[str search_arn = arn elif service == 'kms': search_arn = arn + elif service == 'secretsmanager': + search_arn = arn else: raise NotImplementedError('Service policies for {} are not (currently) cached.'.format(service)) for policy in policies: if search_arn == policy.arn: - return policy.policy_doc + return policy raise ValueError('Unable to locate a cached policy for resource {}'.format(arn)) diff --git a/principalmapper/visualizing/graphviz_writer.py b/principalmapper/visualizing/graphviz_writer.py index c686ed4..ec8c2ec 100644 --- a/principalmapper/visualizing/graphviz_writer.py +++ b/principalmapper/visualizing/graphviz_writer.py @@ -12,6 +12,7 @@ # # You should have received a copy of the GNU Affero General Public License # along with Principal Mapper. If not, see . + from typing import Dict, List import pydot diff --git a/required-permissions.json b/required-permissions.json new file mode 100644 index 0000000..a1c9a13 --- /dev/null +++ b/required-permissions.json @@ -0,0 +1,32 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "PMapperPerms", + "Effect": "Allow", + "Action": [ + "iam:List*", + "iam:Get*", + "organizations:List*", + "organizations:Describe*", + "s3:ListAllMyBuckets", + "s3:ListBucket", + "s3:GetBucketPolicy", + "kms:ListKeys", + "kms:GetKeyPolicy", + "sns:ListTopics", + "sns:GetTopicAttributes", + "sqs:ListQueues", + "sqs:GetQueueAttributes", + "secretsmanager:ListSecrets", + "secretsmanager:GetResourcePolicy", + "cloudformation:DescribeStacks", + "lambda:ListFunctions", + "codebuild:ListProjects", + "codebuild:BatchGetProjects", + "autoscaling:DescribeLaunchConfigurations" + ], + "Resource": "*" + } + ] +} \ No newline at end of file diff --git a/setup.py b/setup.py index 0a495ae..ea072fa 100644 --- a/setup.py +++ b/setup.py @@ -55,6 +55,8 @@ 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', 'Topic :: Security' ], keywords=[