diff --git a/prowler/__main__.py b/prowler/__main__.py index 060c264acc..d8ab4061aa 100644 --- a/prowler/__main__.py +++ b/prowler/__main__.py @@ -40,7 +40,7 @@ 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.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 ( @@ -249,20 +249,22 @@ def prowler(): stats = extract_findings_statistics(findings) if args.slack: + # 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 ): - _ = send_slack_message( - environ["SLACK_API_TOKEN"], - ( - environ["SLACK_CHANNEL_NAME"] - if "SLACK_CHANNEL_NAME" in environ - else environ["SLACK_CHANNEL_ID"] - ), - stats, - global_provider, + + 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_NAME environment variables (see more in https://docs.prowler.cloud/en/latest/tutorials/integrations/#slack)." ) 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/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