diff --git a/cli/cli.py b/cli/cli.py index 9988701623..ce5ada74cb 100644 --- a/cli/cli.py +++ b/cli/cli.py @@ -1,3 +1,5 @@ +from argparse import Namespace + import typer from prowler.config.config import available_compliance_frameworks @@ -16,7 +18,11 @@ from prowler.lib.check.check import ( print_services, ) from prowler.lib.check.checks_loader import load_checks_to_execute +from prowler.lib.check.compliance import update_checks_metadata_with_compliance from prowler.lib.logger import logger, logging_levels, set_logging_config +from prowler.lib.outputs.security_hub.security_hub import SecurityHub +from prowler.lib.scan.scan import Scan +from prowler.providers.common.provider import Provider app = typer.Typer() @@ -83,6 +89,7 @@ def main( log_level: str = typer.Option("INFO", "--log-level", help="Set the Log level"), log_file: str = typer.Option(None, "--log-file", help="Set the Log file"), only_logs: bool = typer.Option(False, "--only-logs", help="Only show logs"), + profile: str = typer.Option(None, "--profile", help="The profile to use"), ): check_provider(provider) if list_services_bool: @@ -152,6 +159,61 @@ def main( else: set_logging_config("INFO", only_logs=True) logger.info("Only logs are shown") + if profile: + # Execute Prowler + checks_to_execute = ["s3_account_level_public_access_blocks"] + # Create the provider + args = Namespace + args.provider = provider + args.profile = profile + args.verbose = False + args.fixer = False + args.only_logs = False + args.status = [] + args.output_formats = [] + args.output_filename = None + args.unix_timestamp = False + args.output_directory = None + args.shodan = None + args.security_hub = False + args.send_sh_only_fails = False + # args.region = ("eu-west-1") + Provider.set_global_provider(args) + provider = Provider.get_global_provider() + bulk_checks_metadata = bulk_load_checks_metadata(provider.type) + bulk_compliance_frameworks = bulk_load_compliance_frameworks(provider.type) + bulk_checks_metadata = update_checks_metadata_with_compliance( + bulk_compliance_frameworks, bulk_checks_metadata + ) + provider.output_options = (args, bulk_checks_metadata) + provider.output_options.bulk_checks_metadata = bulk_checks_metadata + scan = Scan(provider, checks_to_execute) + custom_checks_metadata = None + scan_results = scan.scan(custom_checks_metadata) + # Verify where AWS Security Hub is enabled + aws_security_enabled_regions = [] + security_hub_regions = ( + provider.get_available_aws_service_regions("securityhub") + if not provider.identity.audited_regions + else provider.identity.audited_regions + ) + security_hub = SecurityHub(provider) + for region in security_hub_regions: + # Save the regions where AWS Security Hub is enabled + if security_hub.verify_security_hub_integration_enabled_per_region( + region, + ): + aws_security_enabled_regions.append(region) + # Prepare the findings to be sent to Security Hub + security_hub_findings_per_region = security_hub.prepare_security_hub_findings( + scan_results, + aws_security_enabled_regions, + ) + # Send the findings to Security Hub + findings_sent_to_security_hub = security_hub.batch_send_to_security_hub( + security_hub_findings_per_region + ) + print(findings_sent_to_security_hub) if __name__ == "__main__": diff --git a/prowler/__main__.py b/prowler/__main__.py index d5a0c8c55f..29c1d4892d 100644 --- a/prowler/__main__.py +++ b/prowler/__main__.py @@ -40,15 +40,10 @@ from prowler.lib.outputs.compliance.compliance import display_compliance_table from prowler.lib.outputs.html.html import add_html_footer, fill_html_overview_statistics from prowler.lib.outputs.json.json import close_json from prowler.lib.outputs.outputs import extract_findings_statistics -from prowler.lib.outputs.slack import send_slack_message +from prowler.lib.outputs.security_hub.security_hub import SecurityHub +from prowler.lib.outputs.slack.slack import Slack from prowler.lib.outputs.summary_table import display_summary_table from prowler.providers.aws.lib.s3.s3 import send_to_s3_bucket -from prowler.providers.aws.lib.security_hub.security_hub import ( - batch_send_to_security_hub, - prepare_security_hub_findings, - resolve_security_hub_previous_findings, - verify_security_hub_integration_enabled_per_region, -) from prowler.providers.common.provider import Provider from prowler.providers.common.quick_inventory import run_provider_quick_inventory @@ -248,14 +243,22 @@ def prowler(): stats = extract_findings_statistics(findings) if args.slack: - if "SLACK_API_TOKEN" in environ and "SLACK_CHANNEL_ID" in environ: - _ = send_slack_message( - environ["SLACK_API_TOKEN"], - environ["SLACK_CHANNEL_ID"], - stats, - global_provider, + # TODO: this should be also in a config file + if "SLACK_API_TOKEN" in environ and ( + "SLACK_CHANNEL_NAME" in environ or "SLACK_CHANNEL_ID" in environ + ): + + token = environ["SLACK_API_TOKEN"] + channel = ( + environ["SLACK_CHANNEL_NAME"] + if "SLACK_CHANNEL_NAME" in environ + else environ["SLACK_CHANNEL_ID"] ) + prowler_args = " ".join(sys.argv[1:]) + slack = Slack(token, channel, global_provider) + _ = slack.send(stats, prowler_args) else: + # Refactor(CLI) logger.critical( "Slack integration needs SLACK_API_TOKEN and SLACK_CHANNEL_ID environment variables (see more in https://docs.prowler.cloud/en/latest/tutorials/integrations/#slack)." ) @@ -312,42 +315,43 @@ def prowler(): if not global_provider.identity.audited_regions else global_provider.identity.audited_regions ) + security_hub = SecurityHub(global_provider) + for region in security_hub_regions: # Save the regions where AWS Security Hub is enabled - if verify_security_hub_integration_enabled_per_region( - global_provider.identity.partition, + if security_hub.verify_security_hub_integration_enabled_per_region( region, - global_provider.session.current_session, - global_provider.identity.account, ): aws_security_enabled_regions.append(region) # Prepare the findings to be sent to Security Hub - security_hub_findings_per_region = prepare_security_hub_findings( + security_hub_findings_per_region = security_hub.prepare_security_hub_findings( findings, - global_provider, - global_provider.output_options, aws_security_enabled_regions, ) # Send the findings to Security Hub - findings_sent_to_security_hub = batch_send_to_security_hub( - security_hub_findings_per_region, global_provider.session.current_session + findings_sent_to_security_hub = security_hub.batch_send_to_security_hub( + security_hub_findings_per_region ) + # Refactor(CLI) print( f"{Style.BRIGHT}{Fore.GREEN}\n{findings_sent_to_security_hub} findings sent to AWS Security Hub!{Style.RESET_ALL}" ) # Resolve previous fails of Security Hub if not args.skip_sh_update: + # Refactor(CLI) print( f"{Style.BRIGHT}\nArchiving previous findings in AWS Security Hub, please wait...{Style.RESET_ALL}" ) - findings_archived_in_security_hub = resolve_security_hub_previous_findings( - security_hub_findings_per_region, - global_provider, + findings_archived_in_security_hub = ( + security_hub.resolve_security_hub_previous_findings( + security_hub_findings_per_region, + ) ) + # Refactor(CLI) print( f"{Style.BRIGHT}{Fore.GREEN}\n{findings_archived_in_security_hub} findings archived in AWS Security Hub!{Style.RESET_ALL}" ) diff --git a/prowler/lib/check/check.py b/prowler/lib/check/check.py index 2db7ac1088..62f4f089c2 100644 --- a/prowler/lib/check/check.py +++ b/prowler/lib/check/check.py @@ -593,12 +593,17 @@ def execute_checks( service, check_name, global_provider, - services_executed, - checks_executed, custom_checks_metadata, ) all_findings.extend(check_findings) + # Update Audit Status + services_executed.add(service) + checks_executed.add(check_name) + global_provider.audit_metadata = update_audit_metadata( + global_provider.audit_metadata, services_executed, checks_executed + ) + # If check does not exists in the provider or is from another provider except ModuleNotFoundError: logger.error( @@ -651,12 +656,19 @@ def execute_checks( service, check_name, global_provider, - services_executed, - checks_executed, custom_checks_metadata, ) all_findings.extend(check_findings) + # Update Audit Status + services_executed.add(service) + checks_executed.add(check_name) + global_provider.audit_metadata = update_audit_metadata( + global_provider.audit_metadata, + services_executed, + checks_executed, + ) + # If check does not exists in the provider or is from another provider except ModuleNotFoundError: # TODO: add more loggin here, we need the original exception -- traceback.print_last() @@ -677,8 +689,6 @@ def execute( service: str, check_name: str, global_provider: Any, - services_executed: set, - checks_executed: set, custom_checks_metadata: Any, ): try: @@ -700,13 +710,6 @@ def execute( # Run check check_findings = run_check(check_class, global_provider.output_options) - # Update Audit Status - services_executed.add(service) - checks_executed.add(check_name) - global_provider.audit_metadata = update_audit_metadata( - global_provider.audit_metadata, services_executed, checks_executed - ) - # Mutelist findings if hasattr(global_provider, "mutelist") and global_provider.mutelist: check_findings = mutelist_findings( diff --git a/prowler/providers/aws/lib/security_hub/__init__.py b/prowler/lib/outputs/security_hub/__init__.py similarity index 100% rename from prowler/providers/aws/lib/security_hub/__init__.py rename to prowler/lib/outputs/security_hub/__init__.py diff --git a/prowler/lib/outputs/security_hub/security_hub.py b/prowler/lib/outputs/security_hub/security_hub.py new file mode 100644 index 0000000000..0f6414e923 --- /dev/null +++ b/prowler/lib/outputs/security_hub/security_hub.py @@ -0,0 +1,235 @@ +from boto3.session import Session +from botocore.client import ClientError + +from prowler.config.config import timestamp_utc +from prowler.lib.logger import logger +from prowler.lib.outputs.json_asff.json_asff import fill_json_asff +from prowler.providers.aws.aws_provider import AwsProvider + +SECURITY_HUB_INTEGRATION_NAME = "prowler/prowler" +SECURITY_HUB_MAX_BATCH = 100 + + +class SecurityHub: + _session: Session + _provider: AwsProvider + _account: str + _partition: str + + def __init__(self, provider: AwsProvider) -> "SecurityHub": + self._provider = provider + self._session = provider.session.current_session + self._account = provider.identity.account + self._partition = provider.identity.partition + + @property + def findings_per_region(self): + return self._findings_per_region + + def prepare_security_hub_findings( + self, findings: list, enabled_regions: list + ) -> dict: + security_hub_findings_per_region = {} + + # Create a key per audited region + for region in enabled_regions: + security_hub_findings_per_region[region] = [] + + for finding in findings: + # We don't send the MANUAL findings to AWS Security Hub + if finding.status == "MANUAL": + continue + + # We don't send findings to not enabled regions + if finding.region not in enabled_regions: + continue + + if ( + finding.status != "FAIL" or finding.muted + ) and self._provider.output_options.send_sh_only_fails: + continue + + if self._provider.output_options.status: + if finding.status not in self._provider.output_options.status: + continue + + if finding.muted: + continue + + # Get the finding region + region = finding.region + + # Format the finding in the JSON ASFF format + finding_json_asff = fill_json_asff(self._provider, finding) + + # Include that finding within their region in the JSON format + security_hub_findings_per_region[region].append( + finding_json_asff.dict(exclude_none=True) + ) + + return security_hub_findings_per_region + + def verify_security_hub_integration_enabled_per_region( + self, + region: str, + ) -> bool: + f""" + verify_security_hub_integration_enabled returns True if the {SECURITY_HUB_INTEGRATION_NAME} is enabled for the given region. Otherwise returns false. + """ + prowler_integration_enabled = False + + try: + logger.info( + f"Checking if the {SECURITY_HUB_INTEGRATION_NAME} is enabled in the {region} region." + ) + # Check if security hub is enabled in current region + security_hub_client = self._session.client( + "securityhub", region_name=region + ) + security_hub_client.describe_hub() + + # Check if Prowler integration is enabled in Security Hub + security_hub_prowler_integration_arn = f"arn:{self._partition}:securityhub:{region}:{self._account}:product-subscription/{SECURITY_HUB_INTEGRATION_NAME}" + if security_hub_prowler_integration_arn not in str( + security_hub_client.list_enabled_products_for_import() + ): + logger.warning( + f"Security Hub is enabled in {region} but Prowler integration does not accept findings. More info: https://docs.prowler.cloud/en/latest/tutorials/aws/securityhub/" + ) + else: + prowler_integration_enabled = True + + # Handle all the permissions / configuration errors + except ClientError as client_error: + # Check if Account is subscribed to Security Hub + error_code = client_error.response["Error"]["Code"] + error_message = client_error.response["Error"]["Message"] + if ( + error_code == "InvalidAccessException" + and f"Account {self._account} is not subscribed to AWS Security Hub" + in error_message + ): + logger.warning( + f"{client_error.__class__.__name__} -- [{client_error.__traceback__.tb_lineno}]: {client_error}" + ) + else: + logger.error( + f"{client_error.__class__.__name__} -- [{client_error.__traceback__.tb_lineno}]: {client_error}" + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__} -- [{error.__traceback__.tb_lineno}]: {error}" + ) + + finally: + return prowler_integration_enabled + + def batch_send_to_security_hub( + self, + security_hub_findings_per_region: dict, + ) -> int: + """ + send_to_security_hub sends findings to Security Hub and returns the number of findings that were successfully sent. + """ + + success_count = 0 + try: + # Iterate findings by region + for region, findings in security_hub_findings_per_region.items(): + # Send findings to Security Hub + logger.info(f"Sending findings to Security Hub in the region {region}") + + security_hub_client = self._session.client( + "securityhub", region_name=region + ) + + success_count += self.__send_findings_to_security_hub__( + findings, region, security_hub_client + ) + + except Exception as error: + logger.error( + f"{error.__class__.__name__} -- [{error.__traceback__.tb_lineno}]:{error} in region {region}" + ) + return success_count + + # Move previous Security Hub check findings to ARCHIVED (as prowler didn't re-detect them) + def resolve_security_hub_previous_findings( + self, security_hub_findings_per_region: dict + ) -> list: + """ + resolve_security_hub_previous_findings archives all the findings that does not appear in the current execution + """ + logger.info("Checking previous findings in Security Hub to archive them.") + success_count = 0 + for region in security_hub_findings_per_region.keys(): + try: + current_findings = security_hub_findings_per_region[region] + # Get current findings IDs + current_findings_ids = [] + for finding in current_findings: + current_findings_ids.append(finding["Id"]) + # Get findings of that region + security_hub_client = self._session.client( + "securityhub", region_name=region + ) + findings_filter = { + "ProductName": [{"Value": "Prowler", "Comparison": "EQUALS"}], + "RecordState": [{"Value": "ACTIVE", "Comparison": "EQUALS"}], + "AwsAccountId": [{"Value": self._account, "Comparison": "EQUALS"}], + "Region": [{"Value": region, "Comparison": "EQUALS"}], + } + get_findings_paginator = security_hub_client.get_paginator( + "get_findings" + ) + findings_to_archive = [] + for page in get_findings_paginator.paginate(Filters=findings_filter): + # Archive findings that have not appear in this execution + for finding in page["Findings"]: + if finding["Id"] not in current_findings_ids: + finding["RecordState"] = "ARCHIVED" + finding["UpdatedAt"] = timestamp_utc.strftime( + "%Y-%m-%dT%H:%M:%SZ" + ) + + findings_to_archive.append(finding) + logger.info(f"Archiving {len(findings_to_archive)} findings.") + + # Send archive findings to SHub + success_count += self.__send_findings_to_security_hub__( + findings_to_archive, region, security_hub_client + ) + except Exception as error: + logger.error( + f"{error.__class__.__name__} -- [{error.__traceback__.tb_lineno}]:{error} in region {region}" + ) + return success_count + + def __send_findings_to_security_hub__( + self, findings: list[dict], region: str, security_hub_client + ): + """Private function send_findings_to_security_hub chunks the findings in groups of 100 findings and send them to AWS Security Hub. It returns the number of sent findings.""" + success_count = 0 + try: + list_chunked = [ + findings[i : i + SECURITY_HUB_MAX_BATCH] + for i in range(0, len(findings), SECURITY_HUB_MAX_BATCH) + ] + + for findings in list_chunked: + batch_import = security_hub_client.batch_import_findings( + Findings=findings + ) + if batch_import["FailedCount"] > 0: + failed_import = batch_import["FailedFindings"][0] + logger.error( + f"Failed to send findings to AWS Security Hub -- {failed_import['ErrorCode']} -- {failed_import['ErrorMessage']}" + ) + success_count += batch_import["SuccessCount"] + + except Exception as error: + logger.error( + f"{error.__class__.__name__} -- [{error.__traceback__.tb_lineno}]:{error} in region {region}" + ) + finally: + return success_count diff --git a/prowler/lib/outputs/slack.py b/prowler/lib/outputs/slack.py deleted file mode 100644 index 9902161e82..0000000000 --- a/prowler/lib/outputs/slack.py +++ /dev/null @@ -1,156 +0,0 @@ -import sys - -from slack_sdk import WebClient - -from prowler.config.config import aws_logo, azure_logo, gcp_logo, square_logo_img -from prowler.lib.logger import logger - - -def send_slack_message(token, channel, stats, provider): - try: - client = WebClient(token=token) - identity, logo = create_message_identity(provider) - response = client.chat_postMessage( - username="Prowler", - icon_url=square_logo_img, - channel=f"#{channel}", - blocks=create_message_blocks(identity, logo, stats), - ) - return response - except Exception as error: - logger.error( - f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" - ) - - -# TODO: move this to each provider -def create_message_identity(provider): - """ - Create a Slack message identity based on the provider type. - - Parameters: - - provider (Provider): The Provider (e.g. "AwsProvider", "GcpProvider", "AzureProvide"). - - Returns: - - identity (str): The message identity based on the provider type. - - logo (str): The logo URL associated with the provider type. - """ - try: - identity = "" - logo = aws_logo - if provider.type == "aws": - identity = f"AWS Account *{provider.identity.account}*" - elif provider.type == "gcp": - identity = f"GCP Projects *{', '.join(provider.project_ids)}*" - logo = gcp_logo - elif provider.type == "azure": - printed_subscriptions = [] - for key, value in provider.identity.subscriptions.items(): - intermediate = f"- *{key}: {value}*\n" - printed_subscriptions.append(intermediate) - identity = f"Azure Subscriptions:\n{''.join(printed_subscriptions)}" - logo = azure_logo - return identity, logo - except Exception as error: - logger.error( - f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" - ) - - -def create_title(identity, stats): - try: - title = f"Hey there 👋 \n I'm *Prowler*, _the handy multi-cloud security tool_ :cloud::key:\n\n I have just finished the security assessment on your {identity} with a total of *{stats['findings_count']}* findings." - return title - except Exception as error: - logger.error( - f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" - ) - - -def create_message_blocks(identity, logo, stats): - try: - blocks = [ - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": create_title(identity, stats), - }, - "accessory": { - "type": "image", - "image_url": logo, - "alt_text": "Provider Logo", - }, - }, - {"type": "divider"}, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": f"\n:white_check_mark: *{stats['total_pass']} Passed findings* ({round(stats['total_pass'] / stats['findings_count'] * 100 , 2)}%)\n", - }, - }, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": f"\n:x: *{stats['total_fail']} Failed findings* ({round(stats['total_fail'] / stats['findings_count'] * 100 , 2)}%)\n ", - }, - }, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": f"\n:bar_chart: *{stats['resources_count']} Scanned Resources*\n", - }, - }, - {"type": "divider"}, - { - "type": "context", - "elements": [ - { - "type": "mrkdwn", - "text": f"Used parameters: `prowler {' '.join(sys.argv[1:])} `", - } - ], - }, - {"type": "divider"}, - { - "type": "section", - "text": {"type": "mrkdwn", "text": "Join our Slack Community!"}, - "accessory": { - "type": "button", - "text": {"type": "plain_text", "text": "Prowler :slack:"}, - "url": "https://join.slack.com/t/prowler-workspace/shared_invite/zt-1hix76xsl-2uq222JIXrC7Q8It~9ZNog", - }, - }, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "Feel free to contact us in our repo", - }, - "accessory": { - "type": "button", - "text": {"type": "plain_text", "text": "Prowler :github:"}, - "url": "https://github.com/prowler-cloud/prowler", - }, - }, - { - "type": "section", - "text": { - "type": "mrkdwn", - "text": "See all the things you can do with ProwlerPro", - }, - "accessory": { - "type": "button", - "text": {"type": "plain_text", "text": "Prowler Pro"}, - "url": "https://prowler.pro", - }, - }, - ] - return blocks - except Exception as error: - logger.error( - f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" - ) diff --git a/prowler/lib/outputs/slack/__init__.py b/prowler/lib/outputs/slack/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/prowler/lib/outputs/slack/slack.py b/prowler/lib/outputs/slack/slack.py new file mode 100644 index 0000000000..a4d75b42ae --- /dev/null +++ b/prowler/lib/outputs/slack/slack.py @@ -0,0 +1,206 @@ +from typing import Any + +from slack_sdk import WebClient +from slack_sdk.web.base_client import SlackResponse + +from prowler.config.config import aws_logo, azure_logo, gcp_logo, square_logo_img +from prowler.lib.logger import logger + + +class Slack: + _provider: Any + _token: str + _channel: str + + def __init__(self, token: str, channel: str, provider: Any) -> "Slack": + self._token = token + self._channel = channel + self._provider = provider + + @property + def token(self): + return self._token + + @property + def channel(self): + return self._channel + + def send(self, stats: dict, args: str) -> SlackResponse: + """ + Sends the findings to Slack. + + Args: + stats (dict): A dictionary containing audit statistics. + args (str): Command line arguments used for the audit. + + Returns: + SlackResponse: Slack response if successful, error object if an exception occurs. + """ + try: + client = WebClient(token=self.token) + identity, logo = self.__create_message_identity__(self._provider) + response = client.chat_postMessage( + username="Prowler", + icon_url=square_logo_img, + channel=f"#{self.channel}", + blocks=self.__create_message_blocks__(identity, logo, stats, args), + ) + return response + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + return error + + def __create_message_identity__(self, provider: Any): + """ + Create a Slack message identity based on the provider type. + + Parameters: + - provider (Provider): The Provider (e.g. "AwsProvider", "GcpProvider", "AzureProvide"). + + Returns: + - identity (str): The message identity based on the provider type. + - logo (str): The logo URL associated with the provider type. + """ + + # TODO: support kubernetes + try: + identity = "" + logo = aws_logo + if provider.type == "aws": + identity = f"AWS Account *{provider.identity.account}*" + elif provider.type == "gcp": + identity = f"GCP Projects *{', '.join(provider.project_ids)}*" + logo = gcp_logo + elif provider.type == "azure": + printed_subscriptions = [] + for key, value in provider.identity.subscriptions.items(): + intermediate = f"- *{key}: {value}*\n" + printed_subscriptions.append(intermediate) + identity = f"Azure Subscriptions:\n{''.join(printed_subscriptions)}" + logo = azure_logo + return identity, logo + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def __create_message_blocks__(self, identity, logo, stats, args) -> list: + """ + Create the Slack message blocks. + + Args: + identity: message identity. + logo: logo URL. + stats: audit statistics. + args: command line arguments used. + + Returns: + list: list of Slack message blocks. + """ + try: + blocks = [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": self.__create_title__(identity, stats), + }, + "accessory": { + "type": "image", + "image_url": logo, + "alt_text": "Provider Logo", + }, + }, + {"type": "divider"}, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"\n:white_check_mark: *{stats['total_pass']} Passed findings* ({round(stats['total_pass'] / stats['findings_count'] * 100 , 2)}%)\n", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"\n:x: *{stats['total_fail']} Failed findings* ({round(stats['total_fail'] / stats['findings_count'] * 100 , 2)}%)\n ", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"\n:bar_chart: *{stats['resources_count']} Scanned Resources*\n", + }, + }, + {"type": "divider"}, + { + "type": "context", + "elements": [ + { + "type": "mrkdwn", + "text": f"Used parameters: `prowler {args}`", + } + ], + }, + {"type": "divider"}, + { + "type": "section", + "text": {"type": "mrkdwn", "text": "Join our Slack Community!"}, + "accessory": { + "type": "button", + "text": {"type": "plain_text", "text": "Prowler :slack:"}, + "url": "https://join.slack.com/t/prowler-workspace/shared_invite/zt-1hix76xsl-2uq222JIXrC7Q8It~9ZNog", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Feel free to contact us in our repo", + }, + "accessory": { + "type": "button", + "text": {"type": "plain_text", "text": "Prowler :github:"}, + "url": "https://github.com/prowler-cloud/prowler", + }, + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "See all the things you can do with ProwlerPro", + }, + "accessory": { + "type": "button", + "text": {"type": "plain_text", "text": "Prowler Pro"}, + "url": "https://prowler.pro", + }, + }, + ] + return blocks + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + def __create_title__(self, identity, stats) -> str: + """ + Create the Slack message title. + + Args: + identity: message identity. + stats: audit statistics. + + Returns: + str: Slack message title. + """ + try: + title = f"Hey there 👋 \n I'm *Prowler*, _the handy multi-cloud security tool_ :cloud::key:\n\n I have just finished the security assessment on your {identity} with a total of *{stats['findings_count']}* findings." + return title + except Exception as error: + logger.error( + f"{error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) diff --git a/prowler/lib/scan/scan.py b/prowler/lib/scan/scan.py index 84e32187ad..85e285ae79 100644 --- a/prowler/lib/scan/scan.py +++ b/prowler/lib/scan/scan.py @@ -1,60 +1,150 @@ from typing import Any -from prowler.lib.check.check import execute +from prowler.lib.check.check import execute, update_audit_metadata from prowler.lib.check.models import Check_Report from prowler.lib.logger import logger from prowler.providers.common.models import Audit_Metadata +from prowler.providers.common.provider import Provider -def scan( - checks_to_execute: list, - global_provider: Any, - custom_checks_metadata: Any, -) -> list[Check_Report]: - try: - # List to store all the check's findings - all_findings = [] - # Services and checks executed for the Audit Status - services_executed = set() - checks_executed = set() +class Scan: + # Maybe not needed + _provider: Provider + # Refactor(Core): This should replace the Audit_Metadata + _number_of_checks_to_execute: int = 0 + _number_of_checks_completed: int = 0 + # TODO: these should hold a list of Checks() + _checks_to_execute: set[str] + _service_checks_to_execute: dict[str, set[str]] + _service_checks_completed: dict[str, set[str]] + _progress: float = 0.0 + _findings: list = [] - # Initialize the Audit Metadata - # TODO: this should be done in the provider class - # Refactor(Core): Audit manager? - global_provider.audit_metadata = Audit_Metadata( - services_scanned=0, - expected_checks=checks_to_execute, - completed_checks=0, - audit_progress=0, - ) + def __init__(self, provider, checks_to_execute): + self._provider = provider - for check_name in checks_to_execute: - try: - # Recover service from check name - service = check_name.split("_")[0] + self._number_of_checks_to_execute = len(checks_to_execute) - check_findings = execute( - service, - check_name, - global_provider, - services_executed, - checks_executed, - custom_checks_metadata, - ) - all_findings.extend(check_findings) + service_checks_to_execute = dict() + service_checks_completed = dict() - # If check does not exists in the provider or is from another provider - except ModuleNotFoundError: - logger.error( - f"Check '{check_name}' was not found for the {global_provider.type.upper()} provider" - ) - except Exception as error: - logger.error( - f"{check_name} - {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" - ) - except Exception as error: - logger.error( - f"{check_name} - {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" - ) + for check in checks_to_execute: + # check -> accessanalyzer_enabled + # service -> accessanalyzer + service = get_service_name_from_check_name(check) + if service not in service_checks_to_execute: + service_checks_to_execute[service] = set() + service_checks_to_execute[service].add(check) - return all_findings + self._service_checks_to_execute = service_checks_to_execute + self._service_checks_completed = service_checks_completed + self._checks_to_execute = checks_to_execute + + @property + def checks_to_execute(self) -> set[str]: + return self._checks_to_execute + + @property + def service_checks_to_execute(self) -> dict[str, set[str]]: + return self._service_checks_to_execute + + @property + def service_checks_completed(self) -> dict[str, set[str]]: + return self._service_checks_completed + + @property + def provider(self) -> Provider: + return self._provider + + @property + def progress(self) -> float: + return self._number_of_checks_completed / self._number_of_checks_to_execute + + @property + def findings(self) -> list: + return self._findings + + def scan( + self, + custom_checks_metadata: Any, + ) -> list[Check_Report]: + try: + checks_to_execute = self.checks_to_execute + # Initialize the Audit Metadata + # TODO: this should be done in the provider class + # Refactor(Core): Audit manager? + self._provider.audit_metadata = Audit_Metadata( + services_scanned=0, # Refactor(Core): This shouldn't be nee + expected_checks=checks_to_execute, + completed_checks=0, + audit_progress=0, + ) + + for check_name in checks_to_execute: + try: + # Recover service from check name + service = get_service_name_from_check_name(check_name) + + # Execute the check + check_findings = execute( + service, + check_name, + self._provider, + custom_checks_metadata, + ) + # Store findings + self._findings.extend(check_findings) + + # Remove the executed check + self._service_checks_to_execute[service].remove(check_name) + if len(self._service_checks_to_execute[service]) == 0: + self._service_checks_to_execute.pop(service, None) + # Add the completed check + if service not in self._service_checks_completed: + self._service_checks_completed[service] = set() + self._service_checks_completed[service].add(check_name) + self._number_of_checks_completed += 1 + + # This should be done just once all the service's checks are completed + # This metadata needs to get to the services not within the provider + # since it is present in the Scan class + self._provider.audit_metadata = update_audit_metadata( + self._provider.audit_metadata, + self.get_completed_services(), + self.get_completed_checks(), + ) + + # If check does not exists in the provider or is from another provider + except ModuleNotFoundError: + logger.error( + f"Check '{check_name}' was not found for the {self._provider.type.upper()} provider" + ) + except Exception as error: + logger.error( + f"{check_name} - {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + except Exception as error: + logger.error( + f"{check_name} - {error.__class__.__name__}[{error.__traceback__.tb_lineno}]: {error}" + ) + + return self._findings + + def get_completed_services(self): + return self._service_checks_completed.keys() + + def get_completed_checks(self): + completed_checks = set() + for checks in self._service_checks_completed.values(): + completed_checks.update(checks) + return completed_checks + + +def get_service_name_from_check_name(check_name: str) -> str: + """ + get_service_name_from_check_name returns the service name for a given check name. + + Example: + get_service_name_from_check_name("ec2_instance_public") -> "ec2" + """ + return check_name.split("_")[0] diff --git a/prowler/providers/aws/lib/security_hub/security_hub.py b/prowler/providers/aws/lib/security_hub/security_hub.py deleted file mode 100644 index 65e2ac1240..0000000000 --- a/prowler/providers/aws/lib/security_hub/security_hub.py +++ /dev/null @@ -1,216 +0,0 @@ -from boto3 import session -from botocore.client import ClientError - -from prowler.config.config import timestamp_utc -from prowler.lib.logger import logger -from prowler.lib.outputs.json_asff.json_asff import fill_json_asff - -SECURITY_HUB_INTEGRATION_NAME = "prowler/prowler" -SECURITY_HUB_MAX_BATCH = 100 - - -def prepare_security_hub_findings( - findings: list, provider, output_options, enabled_regions: list -) -> dict: - security_hub_findings_per_region = {} - - # Create a key per audited region - for region in enabled_regions: - security_hub_findings_per_region[region] = [] - - for finding in findings: - # We don't send the MANUAL findings to AWS Security Hub - if finding.status == "MANUAL": - continue - - # We don't send findings to not enabled regions - if finding.region not in enabled_regions: - continue - - if ( - finding.status != "FAIL" or finding.muted - ) and output_options.send_sh_only_fails: - continue - - if output_options.status: - if finding.status not in output_options.status: - continue - - if finding.muted: - continue - - # Get the finding region - region = finding.region - - # Format the finding in the JSON ASFF format - finding_json_asff = fill_json_asff(provider, finding) - - # Include that finding within their region in the JSON format - security_hub_findings_per_region[region].append( - finding_json_asff.dict(exclude_none=True) - ) - - return security_hub_findings_per_region - - -def verify_security_hub_integration_enabled_per_region( - partition: str, - region: str, - session: session.Session, - aws_account_number: str, -) -> bool: - f"""verify_security_hub_integration_enabled returns True if the {SECURITY_HUB_INTEGRATION_NAME} is enabled for the given region. Otherwise returns false.""" - prowler_integration_enabled = False - - try: - logger.info( - f"Checking if the {SECURITY_HUB_INTEGRATION_NAME} is enabled in the {region} region." - ) - # Check if security hub is enabled in current region - security_hub_client = session.client("securityhub", region_name=region) - security_hub_client.describe_hub() - - # Check if Prowler integration is enabled in Security Hub - security_hub_prowler_integration_arn = f"arn:{partition}:securityhub:{region}:{aws_account_number}:product-subscription/{SECURITY_HUB_INTEGRATION_NAME}" - if security_hub_prowler_integration_arn not in str( - security_hub_client.list_enabled_products_for_import() - ): - logger.warning( - f"Security Hub is enabled in {region} but Prowler integration does not accept findings. More info: https://docs.prowler.cloud/en/latest/tutorials/aws/securityhub/" - ) - else: - prowler_integration_enabled = True - - # Handle all the permissions / configuration errors - except ClientError as client_error: - # Check if Account is subscribed to Security Hub - error_code = client_error.response["Error"]["Code"] - error_message = client_error.response["Error"]["Message"] - if ( - error_code == "InvalidAccessException" - and f"Account {aws_account_number} is not subscribed to AWS Security Hub" - in error_message - ): - logger.warning( - f"{client_error.__class__.__name__} -- [{client_error.__traceback__.tb_lineno}]: {client_error}" - ) - else: - logger.error( - f"{client_error.__class__.__name__} -- [{client_error.__traceback__.tb_lineno}]: {client_error}" - ) - except Exception as error: - logger.error( - f"{error.__class__.__name__} -- [{error.__traceback__.tb_lineno}]: {error}" - ) - - finally: - return prowler_integration_enabled - - -def batch_send_to_security_hub( - security_hub_findings_per_region: dict, - session: session.Session, -) -> int: - """ - send_to_security_hub sends findings to Security Hub and returns the number of findings that were successfully sent. - """ - - success_count = 0 - try: - # Iterate findings by region - for region, findings in security_hub_findings_per_region.items(): - # Send findings to Security Hub - logger.info(f"Sending findings to Security Hub in the region {region}") - - security_hub_client = session.client("securityhub", region_name=region) - - success_count = __send_findings_to_security_hub__( - findings, region, security_hub_client - ) - - except Exception as error: - logger.error( - f"{error.__class__.__name__} -- [{error.__traceback__.tb_lineno}]:{error} in region {region}" - ) - return success_count - - -# Move previous Security Hub check findings to ARCHIVED (as prowler didn't re-detect them) -def resolve_security_hub_previous_findings( - security_hub_findings_per_region: dict, provider -) -> list: - """ - resolve_security_hub_previous_findings archives all the findings that does not appear in the current execution - """ - logger.info("Checking previous findings in Security Hub to archive them.") - success_count = 0 - for region in security_hub_findings_per_region.keys(): - try: - current_findings = security_hub_findings_per_region[region] - # Get current findings IDs - current_findings_ids = [] - for finding in current_findings: - current_findings_ids.append(finding["Id"]) - # Get findings of that region - security_hub_client = provider.session.current_session.client( - "securityhub", region_name=region - ) - findings_filter = { - "ProductName": [{"Value": "Prowler", "Comparison": "EQUALS"}], - "RecordState": [{"Value": "ACTIVE", "Comparison": "EQUALS"}], - "AwsAccountId": [ - {"Value": provider.identity.account, "Comparison": "EQUALS"} - ], - "Region": [{"Value": region, "Comparison": "EQUALS"}], - } - get_findings_paginator = security_hub_client.get_paginator("get_findings") - findings_to_archive = [] - for page in get_findings_paginator.paginate(Filters=findings_filter): - # Archive findings that have not appear in this execution - for finding in page["Findings"]: - if finding["Id"] not in current_findings_ids: - finding["RecordState"] = "ARCHIVED" - finding["UpdatedAt"] = timestamp_utc.strftime( - "%Y-%m-%dT%H:%M:%SZ" - ) - - findings_to_archive.append(finding) - logger.info(f"Archiving {len(findings_to_archive)} findings.") - - # Send archive findings to SHub - success_count += __send_findings_to_security_hub__( - findings_to_archive, region, security_hub_client - ) - except Exception as error: - logger.error( - f"{error.__class__.__name__} -- [{error.__traceback__.tb_lineno}]:{error} in region {region}" - ) - return success_count - - -def __send_findings_to_security_hub__( - findings: list[dict], region: str, security_hub_client -): - """Private function send_findings_to_security_hub chunks the findings in groups of 100 findings and send them to AWS Security Hub. It returns the number of sent findings.""" - success_count = 0 - try: - list_chunked = [ - findings[i : i + SECURITY_HUB_MAX_BATCH] - for i in range(0, len(findings), SECURITY_HUB_MAX_BATCH) - ] - - for findings in list_chunked: - batch_import = security_hub_client.batch_import_findings(Findings=findings) - if batch_import["FailedCount"] > 0: - failed_import = batch_import["FailedFindings"][0] - logger.error( - f"Failed to send findings to AWS Security Hub -- {failed_import['ErrorCode']} -- {failed_import['ErrorMessage']}" - ) - success_count += batch_import["SuccessCount"] - - except Exception as error: - logger.error( - f"{error.__class__.__name__} -- [{error.__traceback__.tb_lineno}]:{error} in region {region}" - ) - finally: - return success_count diff --git a/prowler/providers/common/models.py b/prowler/providers/common/models.py index b380e9eab6..773e5f9888 100644 --- a/prowler/providers/common/models.py +++ b/prowler/providers/common/models.py @@ -7,6 +7,7 @@ from prowler.providers.common.provider import Provider # TODO: include this for all the providers +# Rename to AuditMetadata or ScanMetadata class Audit_Metadata(BaseModel): services_scanned: int # We can't use a set in the expected diff --git a/tests/providers/aws/lib/security_hub/fixtures/metadata.json b/tests/lib/outputs/security_hub/fixtures/metadata.json similarity index 100% rename from tests/providers/aws/lib/security_hub/fixtures/metadata.json rename to tests/lib/outputs/security_hub/fixtures/metadata.json diff --git a/tests/providers/aws/lib/security_hub/security_hub_test.py b/tests/lib/outputs/security_hub/security_hub_test.py similarity index 72% rename from tests/providers/aws/lib/security_hub/security_hub_test.py rename to tests/lib/outputs/security_hub/security_hub_test.py index ad14dba694..1d68a13d76 100644 --- a/tests/providers/aws/lib/security_hub/security_hub_test.py +++ b/tests/lib/outputs/security_hub/security_hub_test.py @@ -1,21 +1,16 @@ +from argparse import Namespace from logging import ERROR, WARNING from os import path import botocore -from boto3 import session from botocore.client import ClientError -from mock import MagicMock, patch +from mock import patch from prowler.config.config import prowler_version, timestamp_utc from prowler.lib.check.models import Check_Report, load_check_metadata -from prowler.providers.aws.lib.security_hub.security_hub import ( - batch_send_to_security_hub, - prepare_security_hub_findings, - verify_security_hub_integration_enabled_per_region, -) +from prowler.lib.outputs.security_hub.security_hub import SecurityHub from tests.providers.aws.utils import ( AWS_ACCOUNT_NUMBER, - AWS_COMMERCIAL_PARTITION, AWS_REGION_EU_WEST_1, AWS_REGION_EU_WEST_2, set_mocked_aws_provider, @@ -108,37 +103,25 @@ class Test_SecurityHub: return finding - def set_mocked_output_options( - self, status: list[str] = [], send_sh_only_fails: bool = False - ): - output_options = MagicMock - output_options.bulk_checks_metadata = {} - output_options.status = status - output_options.send_sh_only_fails = send_sh_only_fails - - return output_options - - def set_mocked_session(self, region): - # Create mock session - return session.Session( - region_name=region, - ) - @patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) def test_verify_security_hub_integration_enabled_per_region(self): - session = self.set_mocked_session(AWS_REGION_EU_WEST_1) - assert verify_security_hub_integration_enabled_per_region( - AWS_COMMERCIAL_PARTITION, AWS_REGION_EU_WEST_1, session, AWS_ACCOUNT_NUMBER + aws_provider = set_mocked_aws_provider() + security_hub = SecurityHub(aws_provider) + + assert security_hub.verify_security_hub_integration_enabled_per_region( + AWS_REGION_EU_WEST_1 ) def test_verify_security_hub_integration_enabled_per_region_security_hub_disabled( self, caplog ): + aws_provider = set_mocked_aws_provider() + security_hub = SecurityHub(aws_provider) + caplog.set_level(WARNING) - session = self.set_mocked_session(AWS_REGION_EU_WEST_1) with patch( - "prowler.providers.aws.lib.security_hub.security_hub.session.Session.client", + "prowler.lib.outputs.security_hub.security_hub.Session.client", ) as mock_security_hub: error_message = f"Account {AWS_ACCOUNT_NUMBER} is not subscribed to AWS Security Hub in region {AWS_REGION_EU_WEST_1}" error_code = "InvalidAccessException" @@ -151,37 +134,33 @@ class Test_SecurityHub: operation_name = "DescribeHub" mock_security_hub.side_effect = ClientError(error_response, operation_name) - assert not verify_security_hub_integration_enabled_per_region( - AWS_COMMERCIAL_PARTITION, + assert not security_hub.verify_security_hub_integration_enabled_per_region( AWS_REGION_EU_WEST_1, - session, - AWS_ACCOUNT_NUMBER, ) assert caplog.record_tuples == [ ( "root", WARNING, - f"ClientError -- [70]: An error occurred ({error_code}) when calling the {operation_name} operation: {error_message}", + f"ClientError -- [86]: An error occurred ({error_code}) when calling the {operation_name} operation: {error_message}", ) ] def test_verify_security_hub_integration_enabled_per_region_prowler_not_subscribed( self, caplog ): + aws_provider = set_mocked_aws_provider() + security_hub = SecurityHub(aws_provider) + caplog.set_level(WARNING) - session = self.set_mocked_session(AWS_REGION_EU_WEST_1) with patch( - "prowler.providers.aws.lib.security_hub.security_hub.session.Session.client", + "prowler.lib.outputs.security_hub.security_hub.Session.client", ) as mock_security_hub: mock_security_hub.describe_hub.return_value = None mock_security_hub.list_enabled_products_for_import.return_value = [] - assert not verify_security_hub_integration_enabled_per_region( - AWS_COMMERCIAL_PARTITION, + assert not security_hub.verify_security_hub_integration_enabled_per_region( AWS_REGION_EU_WEST_1, - session, - AWS_ACCOUNT_NUMBER, ) assert caplog.record_tuples == [ ( @@ -194,11 +173,13 @@ class Test_SecurityHub: def test_verify_security_hub_integration_enabled_per_region_another_ClientError( self, caplog ): + aws_provider = set_mocked_aws_provider() + security_hub = SecurityHub(aws_provider) + caplog.set_level(WARNING) - session = self.set_mocked_session(AWS_REGION_EU_WEST_1) with patch( - "prowler.providers.aws.lib.security_hub.security_hub.session.Session.client", + "prowler.lib.outputs.security_hub.security_hub.Session.client", ) as mock_security_hub: error_message = f"Another exception in region {AWS_REGION_EU_WEST_1}" error_code = "AnotherException" @@ -211,58 +192,52 @@ class Test_SecurityHub: operation_name = "DescribeHub" mock_security_hub.side_effect = ClientError(error_response, operation_name) - assert not verify_security_hub_integration_enabled_per_region( - AWS_COMMERCIAL_PARTITION, + assert not security_hub.verify_security_hub_integration_enabled_per_region( AWS_REGION_EU_WEST_1, - session, - AWS_ACCOUNT_NUMBER, ) assert caplog.record_tuples == [ ( "root", ERROR, - f"ClientError -- [70]: An error occurred ({error_code}) when calling the {operation_name} operation: {error_message}", + f"ClientError -- [86]: An error occurred ({error_code}) when calling the {operation_name} operation: {error_message}", ) ] def test_verify_security_hub_integration_enabled_per_region_another_Exception( self, caplog ): + aws_provider = set_mocked_aws_provider() + security_hub = SecurityHub(aws_provider) + caplog.set_level(WARNING) - session = self.set_mocked_session(AWS_REGION_EU_WEST_1) with patch( - "prowler.providers.aws.lib.security_hub.security_hub.session.Session.client", + "prowler.lib.outputs.security_hub.security_hub.Session.client", ) as mock_security_hub: error_message = f"Another exception in region {AWS_REGION_EU_WEST_1}" mock_security_hub.side_effect = Exception(error_message) - assert not verify_security_hub_integration_enabled_per_region( - AWS_COMMERCIAL_PARTITION, + assert not security_hub.verify_security_hub_integration_enabled_per_region( AWS_REGION_EU_WEST_1, - session, - AWS_ACCOUNT_NUMBER, ) assert caplog.record_tuples == [ ( "root", ERROR, - f"Exception -- [70]: {error_message}", + f"Exception -- [86]: {error_message}", ) ] def test_prepare_security_hub_findings_enabled_region_all_statuses(self): enabled_regions = [AWS_REGION_EU_WEST_1] - output_options = self.set_mocked_output_options() findings = [self.generate_finding("PASS", AWS_REGION_EU_WEST_1)] aws_provider = set_mocked_aws_provider( audited_regions=[AWS_REGION_EU_WEST_1, AWS_REGION_EU_WEST_2] ) + security_hub = SecurityHub(aws_provider) - assert prepare_security_hub_findings( + assert security_hub.prepare_security_hub_findings( findings, - aws_provider, - output_options, enabled_regions, ) == { AWS_REGION_EU_WEST_1: [get_security_hub_finding("PASSED")], @@ -270,104 +245,109 @@ class Test_SecurityHub: def test_prepare_security_hub_findings_all_statuses_MANUAL_finding(self): enabled_regions = [AWS_REGION_EU_WEST_1] - output_options = self.set_mocked_output_options() findings = [self.generate_finding("MANUAL", AWS_REGION_EU_WEST_1)] aws_provider = set_mocked_aws_provider( audited_regions=[AWS_REGION_EU_WEST_1, AWS_REGION_EU_WEST_2] ) + security_hub = SecurityHub(aws_provider) - assert prepare_security_hub_findings( + assert security_hub.prepare_security_hub_findings( findings, - aws_provider, - output_options, enabled_regions, ) == {AWS_REGION_EU_WEST_1: []} def test_prepare_security_hub_findings_disabled_region(self): enabled_regions = [AWS_REGION_EU_WEST_1] - output_options = self.set_mocked_output_options() findings = [self.generate_finding("PASS", AWS_REGION_EU_WEST_2)] aws_provider = set_mocked_aws_provider( audited_regions=[AWS_REGION_EU_WEST_1, AWS_REGION_EU_WEST_2] ) + security_hub = SecurityHub(aws_provider) - assert prepare_security_hub_findings( + assert security_hub.prepare_security_hub_findings( findings, - aws_provider, - output_options, enabled_regions, ) == {AWS_REGION_EU_WEST_1: []} def test_prepare_security_hub_findings_PASS_and_FAIL_statuses(self): enabled_regions = [AWS_REGION_EU_WEST_1] - output_options = self.set_mocked_output_options(status=["FAIL"]) findings = [self.generate_finding("PASS", AWS_REGION_EU_WEST_1)] + + args = Namespace() + args.status = ["FAIL"] aws_provider = set_mocked_aws_provider( - audited_regions=[AWS_REGION_EU_WEST_1, AWS_REGION_EU_WEST_2] + audited_regions=[AWS_REGION_EU_WEST_1, AWS_REGION_EU_WEST_2], arguments=args ) - assert prepare_security_hub_findings( + security_hub = SecurityHub(aws_provider) + + assert security_hub.prepare_security_hub_findings( findings, - aws_provider, - output_options, enabled_regions, ) == {AWS_REGION_EU_WEST_1: []} def test_prepare_security_hub_findings_FAIL_and_FAIL_statuses(self): enabled_regions = [AWS_REGION_EU_WEST_1] - output_options = self.set_mocked_output_options(status=["FAIL"]) findings = [self.generate_finding("FAIL", AWS_REGION_EU_WEST_1)] + + args = Namespace() + args.status = ["FAIL"] aws_provider = set_mocked_aws_provider( - audited_regions=[AWS_REGION_EU_WEST_1, AWS_REGION_EU_WEST_2] + audited_regions=[AWS_REGION_EU_WEST_1, AWS_REGION_EU_WEST_2], arguments=args ) - assert prepare_security_hub_findings( + security_hub = SecurityHub(aws_provider) + + assert security_hub.prepare_security_hub_findings( findings, - aws_provider, - output_options, enabled_regions, ) == {AWS_REGION_EU_WEST_1: [get_security_hub_finding("FAILED")]} def test_prepare_security_hub_findings_send_sh_only_fails_PASS(self): enabled_regions = [AWS_REGION_EU_WEST_1] - output_options = self.set_mocked_output_options(send_sh_only_fails=True) findings = [self.generate_finding("PASS", AWS_REGION_EU_WEST_1)] - aws_provider = set_mocked_aws_provider( - audited_regions=[AWS_REGION_EU_WEST_1, AWS_REGION_EU_WEST_2] - ) - assert prepare_security_hub_findings( + args = Namespace() + args.send_sh_only_fails = True + + aws_provider = set_mocked_aws_provider( + audited_regions=[AWS_REGION_EU_WEST_1, AWS_REGION_EU_WEST_2], arguments=args + ) + security_hub = SecurityHub(aws_provider) + + assert security_hub.prepare_security_hub_findings( findings, - aws_provider, - output_options, enabled_regions, ) == {AWS_REGION_EU_WEST_1: []} def test_prepare_security_hub_findings_send_sh_only_fails_FAIL(self): enabled_regions = [AWS_REGION_EU_WEST_1] - output_options = self.set_mocked_output_options(send_sh_only_fails=True) findings = [self.generate_finding("FAIL", AWS_REGION_EU_WEST_1)] - aws_provider = set_mocked_aws_provider( - audited_regions=[AWS_REGION_EU_WEST_1, AWS_REGION_EU_WEST_2] - ) - assert prepare_security_hub_findings( + args = Namespace() + args.send_sh_only_fails = True + + aws_provider = set_mocked_aws_provider( + audited_regions=[AWS_REGION_EU_WEST_1, AWS_REGION_EU_WEST_2], arguments=args + ) + security_hub = SecurityHub(aws_provider) + + assert security_hub.prepare_security_hub_findings( findings, - aws_provider, - output_options, enabled_regions, ) == {AWS_REGION_EU_WEST_1: [get_security_hub_finding("FAILED")]} def test_prepare_security_hub_findings_no_audited_regions(self): enabled_regions = [AWS_REGION_EU_WEST_1] - output_options = self.set_mocked_output_options() findings = [self.generate_finding("PASS", AWS_REGION_EU_WEST_1)] - aws_provider = set_mocked_aws_provider() - assert prepare_security_hub_findings( + aws_provider = set_mocked_aws_provider( + audited_regions=[AWS_REGION_EU_WEST_1, AWS_REGION_EU_WEST_2] + ) + security_hub = SecurityHub(aws_provider) + + assert security_hub.prepare_security_hub_findings( findings, - aws_provider, - output_options, enabled_regions, ) == { AWS_REGION_EU_WEST_1: [get_security_hub_finding("PASSED")], @@ -375,20 +355,19 @@ class Test_SecurityHub: def test_prepare_security_hub_findings_muted_fail_with_send_sh_only_fails(self): enabled_regions = [AWS_REGION_EU_WEST_1] - output_options = self.set_mocked_output_options( - send_sh_only_fails=True, - ) findings = [ self.generate_finding( status="FAIL", region=AWS_REGION_EU_WEST_1, muted=True ) ] - aws_provider = set_mocked_aws_provider() + args = Namespace() + args.send_sh_only_fails = True - assert prepare_security_hub_findings( + aws_provider = set_mocked_aws_provider(arguments=args) + security_hub = SecurityHub(aws_provider) + + assert security_hub.prepare_security_hub_findings( findings, - aws_provider, - output_options, enabled_regions, ) == { AWS_REGION_EU_WEST_1: [], @@ -396,18 +375,18 @@ class Test_SecurityHub: def test_prepare_security_hub_findings_muted_fail_with_status_FAIL(self): enabled_regions = [AWS_REGION_EU_WEST_1] - output_options = self.set_mocked_output_options(status=["FAIL"]) findings = [ self.generate_finding( status="FAIL", region=AWS_REGION_EU_WEST_1, muted=True ) ] - aws_provider = set_mocked_aws_provider() + args = Namespace() + args.status = ["FAIL"] + aws_provider = set_mocked_aws_provider(arguments=args) + security_hub = SecurityHub(aws_provider) - assert prepare_security_hub_findings( + assert security_hub.prepare_security_hub_findings( findings, - aws_provider, - output_options, enabled_regions, ) == { AWS_REGION_EU_WEST_1: [], @@ -416,24 +395,21 @@ class Test_SecurityHub: @patch("botocore.client.BaseClient._make_api_call", new=mock_make_api_call) def test_batch_send_to_security_hub_one_finding(self): enabled_regions = [AWS_REGION_EU_WEST_1] - output_options = self.set_mocked_output_options() findings = [self.generate_finding("PASS", AWS_REGION_EU_WEST_1)] + aws_provider = set_mocked_aws_provider( audited_regions=[AWS_REGION_EU_WEST_1, AWS_REGION_EU_WEST_2] ) - session = self.set_mocked_session(AWS_REGION_EU_WEST_1) + security_hub = SecurityHub(aws_provider) - security_hub_findings = prepare_security_hub_findings( + security_hub_findings = security_hub.prepare_security_hub_findings( findings, - aws_provider, - output_options, enabled_regions, ) assert ( - batch_send_to_security_hub( + security_hub.batch_send_to_security_hub( security_hub_findings, - session, ) == 1 ) diff --git a/tests/lib/outputs/slack_test.py b/tests/lib/outputs/slack/slack_test.py similarity index 77% rename from tests/lib/outputs/slack_test.py rename to tests/lib/outputs/slack/slack_test.py index 8e38d3eb07..a0afc0c96b 100644 --- a/tests/lib/outputs/slack_test.py +++ b/tests/lib/outputs/slack/slack_test.py @@ -1,13 +1,7 @@ -import sys from unittest import mock from prowler.config.config import aws_logo, azure_logo, gcp_logo -from prowler.lib.outputs.slack import ( - create_message_blocks, - create_message_identity, - create_title, - send_slack_message, -) +from prowler.lib.outputs.slack.slack import Slack from tests.providers.aws.utils import AWS_ACCOUNT_NUMBER, set_mocked_aws_provider from tests.providers.azure.azure_fixtures import ( AZURE_SUBSCRIPTION_ID, @@ -16,28 +10,25 @@ from tests.providers.azure.azure_fixtures import ( ) from tests.providers.gcp.gcp_fixtures import set_mocked_gcp_provider - -def mock_create_message_blocks(*_): - return [{}] - - -def mock_create_message_identity(*_): - return "", "" +SLACK_CHANNEL = "test-channel" +SLACK_TOKEN = "test-token" class TestSlackIntegration: def test_create_message_identity_aws(self): aws_provider = set_mocked_aws_provider() + slack = Slack(SLACK_TOKEN, SLACK_CHANNEL, aws_provider) - assert create_message_identity(aws_provider) == ( + assert slack.__create_message_identity__(aws_provider) == ( f"AWS Account *{aws_provider.identity.account}*", aws_logo, ) def test_create_message_identity_azure(self): azure_provider = set_mocked_azure_provider() + slack = Slack(SLACK_TOKEN, SLACK_CHANNEL, azure_provider) - assert create_message_identity(azure_provider) == ( + assert slack.__create_message_identity__(azure_provider) == ( f"Azure Subscriptions:\n- *{AZURE_SUBSCRIPTION_ID}: {AZURE_SUBSCRIPTION_NAME}*\n", azure_logo, ) @@ -46,27 +37,50 @@ class TestSlackIntegration: gcp_provider = set_mocked_gcp_provider( project_ids=["test-project1", "test-project2"], ) + slack = Slack(SLACK_TOKEN, SLACK_CHANNEL, gcp_provider) - assert create_message_identity(gcp_provider) == ( + assert slack.__create_message_identity__(gcp_provider) == ( f"GCP Projects *{', '.join(gcp_provider.project_ids)}*", gcp_logo, ) - def test_create_message_blocks(self): - aws_identity = f"AWS Account *{AWS_ACCOUNT_NUMBER}*" - azure_identity = "Azure Subscriptions:\n- *subscription 1: qwerty*\n- *subscription 2: asdfg*\n" - gcp_identity = "GCP Project *gcp-project*" + def test_create_title(self): + aws_provider = set_mocked_aws_provider() + slack = Slack(SLACK_TOKEN, SLACK_CHANNEL, aws_provider) + stats = {} stats["total_pass"] = 12 stats["total_fail"] = 10 stats["resources_count"] = 20 stats["findings_count"] = 22 - assert create_message_blocks(aws_identity, aws_logo, stats) == [ + + identity = slack.__create_message_identity__(aws_provider) == ( + f"AWS Account *{aws_provider.identity.account}*", + aws_logo, + ) + assert ( + slack.__create_title__(identity, stats) + == f"Hey there 👋 \n I'm *Prowler*, _the handy multi-cloud security tool_ :cloud::key:\n\n I have just finished the security assessment on your {identity} with a total of *{stats['findings_count']}* findings." + ) + + def test_create_message_blocks_aws(self): + aws_provider = set_mocked_aws_provider() + slack = Slack(SLACK_TOKEN, SLACK_CHANNEL, aws_provider) + args = "--slack" + stats = {} + stats["total_pass"] = 12 + stats["total_fail"] = 10 + stats["resources_count"] = 20 + stats["findings_count"] = 22 + + aws_identity = f"AWS Account *{AWS_ACCOUNT_NUMBER}*" + + assert slack.__create_message_blocks__(aws_identity, aws_logo, stats, args) == [ { "type": "section", "text": { "type": "mrkdwn", - "text": create_title(aws_identity, stats), + "text": slack.__create_title__(aws_identity, stats), }, "accessory": { "type": "image", @@ -102,7 +116,7 @@ class TestSlackIntegration: "elements": [ { "type": "mrkdwn", - "text": f"Used parameters: `prowler {' '.join(sys.argv[1:])} `", + "text": f"Used parameters: `prowler {args}`", } ], }, @@ -141,12 +155,27 @@ class TestSlackIntegration: }, }, ] - assert create_message_blocks(azure_identity, azure_logo, stats) == [ + + def test_create_message_blocks_azure(self): + aws_provider = set_mocked_aws_provider() + slack = Slack(SLACK_TOKEN, SLACK_CHANNEL, aws_provider) + args = "--slack" + stats = {} + stats["total_pass"] = 12 + stats["total_fail"] = 10 + stats["resources_count"] = 20 + stats["findings_count"] = 22 + + azure_identity = "Azure Subscriptions:\n- *subscription 1: qwerty*\n- *subscription 2: asdfg*\n" + + assert slack.__create_message_blocks__( + azure_identity, azure_logo, stats, args + ) == [ { "type": "section", "text": { "type": "mrkdwn", - "text": create_title(azure_identity, stats), + "text": slack.__create_title__(azure_identity, stats), }, "accessory": { "type": "image", @@ -182,7 +211,7 @@ class TestSlackIntegration: "elements": [ { "type": "mrkdwn", - "text": f"Used parameters: `prowler {' '.join(sys.argv[1:])} `", + "text": f"Used parameters: `prowler {args}`", } ], }, @@ -221,12 +250,25 @@ class TestSlackIntegration: }, }, ] - assert create_message_blocks(gcp_identity, gcp_logo, stats) == [ + + def test_create_message_blocks_gcp(self): + aws_provider = set_mocked_aws_provider() + slack = Slack(SLACK_TOKEN, SLACK_CHANNEL, aws_provider) + args = "--slack" + stats = {} + stats["total_pass"] = 12 + stats["total_fail"] = 10 + stats["resources_count"] = 20 + stats["findings_count"] = 22 + + gcp_identity = "GCP Project *gcp-project*" + + assert slack.__create_message_blocks__(gcp_identity, gcp_logo, stats, args) == [ { "type": "section", "text": { "type": "mrkdwn", - "text": create_title(gcp_identity, stats), + "text": slack.__create_title__(gcp_identity, stats), }, "accessory": { "type": "image", @@ -262,7 +304,7 @@ class TestSlackIntegration: "elements": [ { "type": "mrkdwn", - "text": f"Used parameters: `prowler {' '.join(sys.argv[1:])} `", + "text": f"Used parameters: `prowler {args}`", } ], }, @@ -324,14 +366,13 @@ class TestSlackIntegration: mocked_web_client.chat_postMessage = mock.Mock( return_value=mocked_slack_response ) + with mock.patch( - "prowler.lib.outputs.slack.create_message_blocks", - new=mock_create_message_blocks, - ), mock.patch( - "prowler.lib.outputs.slack.create_message_identity", - new=mock_create_message_identity, - ), mock.patch( - "prowler.lib.outputs.slack.WebClient", new=mocked_web_client + "prowler.lib.outputs.slack.slack.WebClient", new=mocked_web_client ): - response = send_slack_message("test-token", "test-channel", {}, "provider") + aws_provider = set_mocked_aws_provider() + slack = Slack(SLACK_TOKEN, SLACK_CHANNEL, aws_provider) + stats = {} + args = "--slack" + response = slack.send(stats, args) assert response == mocked_slack_response diff --git a/tests/providers/aws/utils.py b/tests/providers/aws/utils.py index 9f75c788f7..a21b77040e 100644 --- a/tests/providers/aws/utils.py +++ b/tests/providers/aws/utils.py @@ -153,8 +153,12 @@ def set_mocked_aws_provider( return provider -def set_default_provider_arguments(arguments: Namespace) -> Namespace: +def set_default_provider_arguments(input_arguments: Namespace) -> Namespace: + + arguments = Namespace arguments.status = [] + if hasattr(input_arguments, "status") and input_arguments.status: + arguments.status = input_arguments.status arguments.output_formats = [] arguments.output_directory = "" arguments.verbose = False @@ -162,7 +166,14 @@ def set_default_provider_arguments(arguments: Namespace) -> Namespace: arguments.unix_timestamp = False arguments.shodan = None arguments.security_hub = False + arguments.send_sh_only_fails = False + if ( + hasattr(input_arguments, "send_sh_only_fails") + and input_arguments.send_sh_only_fails + ): + arguments.send_sh_only_fails = input_arguments.send_sh_only_fails + arguments.config_file = default_config_file_path arguments.fixer_config = default_fixer_config_file_path