From 33c68015017ca16cddb6116fcc0ae63063497ea0 Mon Sep 17 00:00:00 2001 From: Nacho Rivera <59198746+n4ch04@users.noreply.github.com> Date: Thu, 16 Jun 2022 12:00:46 +0200 Subject: [PATCH] feat(core): AWS Role Assumption support (#1199) * chore(assuming role): assume role logic and exceptions demo * chore(exceptions): Exception handling * fix(get_caller_identity): Deleted duplicate get_caller_identity and add info entries * chore(creds renewal): Added support to credential renewal * chore(assume options): Added condition for -I/-T options * fix(typo/comments): Deleted f in logger config and comments * chore(session_duration): limits for -T option * fix(log messages): Changed -A/-R log messages * fix(critical error): Errors in input options are critical * fix(ClientError): IAM service ClientError exception support --- Pipfile | 6 +- Pipfile.lock | 26 +-- lib/logger.py | 2 +- providers/aws/aws_provider.py | 189 ++++++++++++++++++++-- providers/aws/services/iam/iam_service.py | 47 ++++-- prowler.py | 126 ++++++++++----- 6 files changed, 326 insertions(+), 70 deletions(-) diff --git a/Pipfile b/Pipfile index 665fcd44..86c061e3 100644 --- a/Pipfile +++ b/Pipfile @@ -4,8 +4,10 @@ verify_ssl = true name = "pypi" [packages] -colorama = "*" -boto3 = "*" +colorama = "0.4.4" +boto3 = "1.24.8" +arnparse = "0.0.2" +botocore = "1.27.8" [dev-packages] diff --git a/Pipfile.lock b/Pipfile.lock index a791bfc4..4adf964f 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "a8f03b76e7a40526ab423d5dae986a85d45c868f02ca9202288d93d45b8f726f" + "sha256": "fd3c94872ea6726d11b810d5eb1e1d23dbf0f94663e2788d38d71d341bad747c" }, "pipfile-spec": 6, "requires": { @@ -16,21 +16,29 @@ ] }, "default": { - "boto3": { + "arnparse": { "hashes": [ - "sha256:28ab0947c49a6fb2409004d4a10b2828aec231cb95ca1d800cb1411e191cc201", - "sha256:833e67edfb73f2cc22ff27a1c33728686dc90a9e81ba2551f9462ea2d1b04f41" + "sha256:b0906734e4b8f19e39b1e32944c6cd6274b6da90c066a83882ac7a11d27553e0", + "sha256:cb87f17200d07121108a9085d4a09cc69a55582647776b9a917b0b1f279db8f8" ], "index": "pypi", - "version": "==1.24.8" + "version": "==0.0.2" + }, + "boto3": { + "hashes": [ + "sha256:0821212ff521cb934801b1f655cef3c0e976775324b1018f1751700d0f42dbb4", + "sha256:87d34861727699c795bf8d65703f2435e75f12879bdd483e08b35b7c5510e8c8" + ], + "index": "pypi", + "version": "==1.24.9" }, "botocore": { "hashes": [ - "sha256:ad92702930d6cb7b587fc2f619672feb74d5218f8de387a28c2905820db79027", - "sha256:db6667b8dfd175d16187653942cd91dd1f0cf36adc0ea9d7a0805ba4d2a3321f" + "sha256:5669b982b0583e73daef1fe0a4df311055e6287326f857dbb1dcc2de1d8412ad", + "sha256:7a7588b0170e571317496ac4104803329d5bc792bc008e8a757ffd440f1b6fa6" ], - "markers": "python_version >= '3.7'", - "version": "==1.27.8" + "index": "pypi", + "version": "==1.27.9" }, "colorama": { "hashes": [ diff --git a/lib/logger.py b/lib/logger.py index 050779b4..a5e5010d 100644 --- a/lib/logger.py +++ b/lib/logger.py @@ -14,7 +14,7 @@ logging_levels = { # https://docs.python.org/3/library/logging.html#logrecord-attributes logging.basicConfig( stream=sys.stdout, - format="%(asctime)s [File: %(filename)s] \t[Module: %(module)s]\t %(levelname)s: %(message)s", + format="%(asctime)s [File: %(filename)s:%(lineno)d] \t[Module: %(module)s]\t %(levelname)s: %(message)s", datefmt="%m/%d/%Y %I:%M:%S %p", ) diff --git a/providers/aws/aws_provider.py b/providers/aws/aws_provider.py index 2f383c12..8553e7e9 100644 --- a/providers/aws/aws_provider.py +++ b/providers/aws/aws_provider.py @@ -1,24 +1,191 @@ +from dataclasses import dataclass +from datetime import datetime + +from arnparse import arnparse from boto3 import session +from botocore.credentials import RefreshableCredentials +from botocore.session import get_session + +from lib.logger import logger + + +@dataclass +class AWS_Credentials: + aws_access_key_id: str + aws_session_token: str + aws_secret_access_key: str + expiration: datetime + + +@dataclass +class Input_Data: + profile: str + role_name: str + account_to_assume: str + session_duration: int + external_id: str + + +@dataclass +class AWS_Assume_Role: + role_name: str + account_to_assume: str + session_duration: int + external_id: str + sts_session: session + caller_identity: str + + +@dataclass +class AWS_Session_Info: + profile: str + credentials: AWS_Credentials + role_info: AWS_Assume_Role ################## AWS PROVIDER class AWS_Provider: - def __init__(self, profile): - self.aws_session = session.Session(profile_name=profile) + def __init__(self, session_info): + self.aws_session = self.set_session(session_info) + self.role_info = session_info.role_info def get_session(self): return self.aws_session + def set_session(self, session_info): + try: + if session_info.credentials: + # If we receive a credentials object filled is coming form an assumed role, so renewal is needed + logger.info("Creating session for assumed role ...") + # From botocore we can use RefreshableCredentials class, which has an attribute (refresh_using) + # that needs to be a method without arguments that retrieves a new set of fresh credentials + # asuming the role again. -> https://github.com/boto/botocore/blob/098cc255f81a25b852e1ecdeb7adebd94c7b1b73/botocore/credentials.py#L395 + assumed_refreshable_credentials = RefreshableCredentials( + access_key=session_info.credentials.aws_access_key_id, + secret_key=session_info.credentials.aws_secret_access_key, + token=session_info.credentials.aws_session_token, + expiry_time=session_info.credentials.expiration, + refresh_using=self.refresh, + method="sts-assume-role", + ) + # Here we need the botocore session since it needs to use refreshable credentials + assumed_botocore_session = get_session() + assumed_botocore_session._credentials = assumed_refreshable_credentials + assumed_botocore_session.set_config_variable("region", "us-east-1") -def provider_set_profile(profile): - global session - session = AWS_Provider(profile).get_session() + return session.Session( + profile_name=session_info.profile, + botocore_session=assumed_botocore_session, + ) + # If we do not receive credentials start the session using the profile + else: + logger.info("Creating session for not assumed identity ...") + return session.Session(profile_name=session_info.profile) + except Exception as error: + logger.critical(f"{error.__class__.__name__} -- {error}") + quit() + + # Refresh credentials method using assume role + # This method is called "adding ()" to the name, so it cannot accept arguments + # https://github.com/boto/botocore/blob/098cc255f81a25b852e1ecdeb7adebd94c7b1b73/botocore/credentials.py#L570 + def refresh(self): + logger.info("Refreshing assumed credentials...") + + response = assume_role(self.role_info) + refreshed_credentials = dict( + # Keys of the dict has to be the same as those that are being searched in the parent class + # https://github.com/boto/botocore/blob/098cc255f81a25b852e1ecdeb7adebd94c7b1b73/botocore/credentials.py#L609 + access_key=response["Credentials"]["AccessKeyId"], + secret_key=response["Credentials"]["SecretAccessKey"], + token=response["Credentials"]["SessionToken"], + expiry_time=response["Credentials"]["Expiration"].isoformat(), + ) + logger.info("Refreshed Credentials:") + logger.info(refreshed_credentials) + return refreshed_credentials -# ################## AWS Service -# class AWS_Service(): -# def __init__(self, service, session): -# self.client = session.client(service) +def validate_credentials(validate_session): -# def get_client(self): -# return self.client + try: + validate_credentials_client = validate_session.client("sts") + caller_identity = validate_credentials_client.get_caller_identity() + except Exception as error: + logger.critical(f"{error.__class__.__name__} -- {error}") + quit() + else: + return caller_identity + + +def provider_set_session(session_input): + global aws_session + global original_session + session_info = AWS_Session_Info( + session_input.profile, + None, + None, + ) + + original_session = AWS_Provider(session_info).get_session() + logger.info("Validating credentials ...") + caller_identity = validate_credentials(original_session) + + role_info = AWS_Assume_Role( + session_input.role_name, + session_input.account_to_assume, + session_input.session_duration, + session_input.external_id, + original_session, + caller_identity, + ) + logger.info("Credentials validated") + logger.info(f"Original caller identity UserId : {caller_identity['UserId']}") + logger.info(f"Original caller identity ARN : {caller_identity['Arn']}") + + if session_input.role_name and session_input.account_to_assume: + logger.info( + f"Assuming role {role_info.role_name} in account {role_info.account_to_assume}" + ) + assumed_role_response = assume_role(role_info) + logger.info("Role assumed") + session_info = AWS_Session_Info( + session_input.profile, + AWS_Credentials( + aws_access_key_id=assumed_role_response["Credentials"]["AccessKeyId"], + aws_session_token=assumed_role_response["Credentials"]["SessionToken"], + aws_secret_access_key=assumed_role_response["Credentials"][ + "SecretAccessKey" + ], + expiration=assumed_role_response["Credentials"]["Expiration"], + ), + role_info, + ) + + aws_session = AWS_Provider(session_info).get_session() + + +def assume_role(role_info): + + try: + sts_client = role_info.sts_session.client("sts") + arn_caller_identity = arnparse(role_info.caller_identity["Arn"]) + role_arn = f"arn:{arn_caller_identity.partition}:iam::{role_info.account_to_assume}:role/{role_info.role_name}" + if role_info.external_id: + assumed_credentials = sts_client.assume_role( + RoleArn=role_arn, + RoleSessionName="ProwlerProSession", + DurationSeconds=role_info.session_duration, + ExternalId=role_info.external_id, + ) + else: + assumed_credentials = sts_client.assume_role( + RoleArn=role_arn, + RoleSessionName="ProwlerProSession", + DurationSeconds=role_info.session_duration, + ) + except Exception as error: + logger.critical(f"{error.__class__.__name__} -- {error}") + quit() + + else: + return assumed_credentials diff --git a/providers/aws/services/iam/iam_service.py b/providers/aws/services/iam/iam_service.py index b67de3d5..1b698ce1 100644 --- a/providers/aws/services/iam/iam_service.py +++ b/providers/aws/services/iam/iam_service.py @@ -1,7 +1,5 @@ -import botocore -from boto3 import session - -from providers.aws.aws_provider import session +from lib.logger import logger +from providers.aws.aws_provider import aws_session ################## IAM @@ -26,7 +24,12 @@ class IAM: try: get_roles_paginator = self.client.get_paginator("list_roles") except botocore.exceptions.ClientError as error: - raise error + logger.error( + f"{error.response['Error']['Code']} -- {error.response['Error']['Message']}" + ) + except Exception as error: + logger.critical(f"{error.__class__.__name__} -- {error}") + quit() else: roles = [] for page in get_roles_paginator.paginate(): @@ -41,7 +44,12 @@ class IAM: try: report_status = self.client.generate_credential_report() except botocore.exceptions.ClientError as error: - raise error + logger.error( + f"{error.response['Error']['Code']} -- {error.response['Error']['Message']}" + ) + except Exception as error: + logger.critical(f"{error.__class__.__name__} -- {error}") + quit() else: if report_status["State"] == "COMPLETE": report_is_completed = True @@ -52,7 +60,12 @@ class IAM: try: get_groups_paginator = self.client.get_paginator("list_groups") except botocore.exceptions.ClientError as error: - raise error + logger.error( + f"{error.response['Error']['Code']} -- {error.response['Error']['Message']}" + ) + except Exception as error: + logger.critical(f"{error.__class__.__name__} -- {error}") + quit() else: groups = [] for page in get_groups_paginator.paginate(): @@ -67,7 +80,12 @@ class IAM: "list_policies" ) except botocore.exceptions.ClientError as error: - raise error + logger.error( + f"{error.response['Error']['Code']} -- {error.response['Error']['Message']}" + ) + except Exception as error: + logger.critical(f"{error.__class__.__name__} -- {error}") + quit() else: customer_managed_policies = [] for page in get_customer_managed_policies_paginator.paginate(Scope="Local"): @@ -80,7 +98,12 @@ class IAM: try: get_users_paginator = self.client.get_paginator("list_users") except botocore.exceptions.ClientError as error: - raise error + logger.error( + f"{error.response['Error']['Code']} -- {error.response['Error']['Message']}" + ) + except Exception as error: + logger.critical(f"{error.__class__.__name__} -- {error}") + quit() else: users = [] for page in get_users_paginator.paginate(): @@ -90,4 +113,8 @@ class IAM: return users -iam_client = IAM(session) +try: + iam_client = IAM(aws_session) +except Exception as error: + logger.critical(f"{error.__class__.__name__} -- {error}") + quit() diff --git a/prowler.py b/prowler.py index 0b67579f..f5d630fc 100644 --- a/prowler.py +++ b/prowler.py @@ -8,7 +8,7 @@ import pkgutil from lib.banner import print_banner, print_version from lib.logger import logger, logging_levels from lib.outputs import report -from providers.aws.aws_provider import provider_set_profile +from providers.aws.aws_provider import Input_Data, provider_set_session def run_check(check): @@ -38,7 +38,7 @@ if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("provider", help="Specify Provider: AWS") parser.add_argument( - "-c", "--checks", nargs="*", help="Comma separated list of checks" + "-c", "--checks", nargs="+", help="Comma separated list of checks" ) parser.add_argument( "-b", "--no-banner", action="store_false", help="Hide Prowler Banner" @@ -56,14 +56,74 @@ if __name__ == "__main__": "-p", "--profile", nargs="?", - const="default", + default=None, help="AWS profile to launch prowler with", ) + parser.add_argument( + "-R", + "--role", + nargs="?", + default=None, + help="Role name to be assumed in account passed with -A", + ) + parser.add_argument( + "-A", + "--account", + nargs="?", + default=None, + help="AWS account id where the role passed by -R is assumed", + ) + parser.add_argument( + "-T", + "--session-duration", + nargs="?", + default=3600, + type=int, + help="Assumed role session duration in seconds, by default 3600", + ) + parser.add_argument( + "-I", + "--external-id", + nargs="?", + default=None, + help="External ID to be passed when assuming role", + ) # Parse Arguments args = parser.parse_args() + provider = args.provider checks = args.checks - profile = args.profile + + # Role assumption input options tests + if args.role or args.account: + if not args.account: + logger.critical( + "It is needed to input an Account Id to assume the role (-A option) when an IAM Role is provided with -R" + ) + quit() + elif not args.role: + logger.critical( + "It is needed to input an IAM Role name (-R option) when an Account Id is provided with -A" + ) + quit() + if args.session_duration not in range(900, 43200): + logger.critical("Value for -T option must be between 900 and 43200") + quit() + if args.session_duration != 3600 or args.external_id: + if not args.account or not args.role: + logger.critical("To use -I/-T options both -A and -R options are needed") + quit() + + session_input = Input_Data( + profile=args.profile, + role_name=args.role, + account_to_assume=args.account, + session_duration=args.session_duration, + external_id=args.external_id, + ) + + # Set Logger + logger.setLevel(logging_levels.get(args.log_level)) if args.version: print_version() @@ -72,44 +132,36 @@ if __name__ == "__main__": if args.no_banner: print_banner() - # Set Logger - logger.setLevel(logging_levels.get(args.log_level)) - - logger.info("Test info") - logger.debug("Test debug") - - # Setting profile - provider_set_profile(profile) + # Setting session + provider_set_session(session_input) # libreria para generar la lista de checks - checks_to_execute = set() - - # LOADER - # Handle if there are checks passed using -c/--checks if checks: - for check_name in checks: - checks_to_execute.add(check_name) + for check in checks: + # Recover service from check name + service = check.split("_")[0] + # Import check module + lib = import_check( + f"providers.{provider}.services.{service}.{check}.{check}" + ) + # Recover functions from check + check_to_execute = getattr(lib, check) + c = check_to_execute() + # Run check + run_check(c) - # If there are no checks passed as argument else: - # Get all check modules to run with the specifie provider + # Get all check modules to run modules = recover_modules_from_provider(provider) + # Run checks for check_module in modules: - # Recover check name from import path (last part) + print(check_module) + # Import check module + lib = import_check(check_module) + # Recover module from check name check_name = check_module.split(".")[5] - checks_to_execute.add(check_name) - - # Execute checks - for check_name in checks_to_execute: - # Recover service from check name - service = check_name.split("_")[0] - # Import check module - # Validate check in service and provider - lib = import_check( - f"providers.{provider}.services.{service}.{check_name}.{check_name}" - ) - # Recover functions from check - check_to_execute = getattr(lib, check_name) - c = check_to_execute() - # Run check - run_check(c) + # Recover functions from check + check_to_execute = getattr(lib, check_name) + c = check_to_execute() + # Run check + run_check(c)