diff --git a/README.md b/README.md index a9ad832..4d76412 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ # n0s1 - Secret Scanner -n0s1 ([pronunciation](https://en.wiktionary.org/wiki/nosy#Pronunciation)) is a secret scanner for Jira, Confluence, Asana, Wrike and Linear.app. It scans all tickets/items/issues within the chosen platform in search of any leaked secrets in the titles, bodies, and comments. It is open-source and it can be easily extended to support scanning many others Project Management and Issue Tracker platforms. +n0s1 ([pronunciation](https://en.wiktionary.org/wiki/nosy#Pronunciation)) is a secret scanner for Slack, Jira, Confluence, Asana, Wrike and Linear. It scans all channels/tickets/items/issues within the chosen platform in search of any leaked secrets in the titles, bodies, messages and comments. It is open-source and it can be easily extended to support scanning many others ticketing and messaging platforms. These secrets are identified by comparing them against an adaptable configuration file named [regex.yaml](https://github.com/spark1security/n0s1/blob/main/src/n0s1/config/regex.yaml). Alternative TOML format is also supported: [regex.toml](https://github.com/spark1security/n0s1/blob/main/src/n0s1/config/regex.toml). The scanner specifically looks for sensitive information, which includes: * Github Personal Access Tokens @@ -23,6 +23,7 @@ These secrets are identified by comparing them against an adaptable configuratio * npm access tokens ### Currently supported target platforms: +* [Slack](https://slack.com) * [Jira](https://www.atlassian.com/software/jira) * [Confluence](https://www.atlassian.com/software/confluence) * [Asana](https://asana.com) diff --git a/requirements.txt b/requirements.txt index d9c3db1..0ea1763 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,5 @@ pyyaml atlassian-python-api asana==3.2.2 WrikePy -BeautifulSoup4 \ No newline at end of file +BeautifulSoup4 +slack_sdk \ No newline at end of file diff --git a/src/n0s1/__init__.py b/src/n0s1/__init__.py index b931c6a..6e3c058 100644 --- a/src/n0s1/__init__.py +++ b/src/n0s1/__init__.py @@ -1 +1 @@ -__version__ = "1.0.19" +__version__ = "1.0.20" diff --git a/src/n0s1/controllers/asana_controller.py b/src/n0s1/controllers/asana_controller.py index ab01fdf..46d4ad2 100644 --- a/src/n0s1/controllers/asana_controller.py +++ b/src/n0s1/controllers/asana_controller.py @@ -7,7 +7,7 @@ import n0s1.controllers.hollow_controller as hollow_controller -class AsanaControler(hollow_controller.HollowController): +class AsanaController(hollow_controller.HollowController): def __init__(self): super().__init__() self._client = None diff --git a/src/n0s1/controllers/confluence_controller.py b/src/n0s1/controllers/confluence_controller.py index 5dcb548..bb3c9f2 100644 --- a/src/n0s1/controllers/confluence_controller.py +++ b/src/n0s1/controllers/confluence_controller.py @@ -9,7 +9,7 @@ import n0s1.controllers.hollow_controller as hollow_controller -class ConfluenceControler(hollow_controller.HollowController): +class ConfluenceController(hollow_controller.HollowController): def __init__(self): super().__init__() self._client = None diff --git a/src/n0s1/controllers/jira_controller.py b/src/n0s1/controllers/jira_controller.py index e502fec..5e8afdf 100644 --- a/src/n0s1/controllers/jira_controller.py +++ b/src/n0s1/controllers/jira_controller.py @@ -7,7 +7,7 @@ import n0s1.controllers.hollow_controller as hollow_controller -class JiraControler(hollow_controller.HollowController): +class JiraController(hollow_controller.HollowController): def __init__(self): super().__init__() self._client = None diff --git a/src/n0s1/controllers/linear_controller.py b/src/n0s1/controllers/linear_controller.py index 103199d..ce2e9f3 100644 --- a/src/n0s1/controllers/linear_controller.py +++ b/src/n0s1/controllers/linear_controller.py @@ -11,7 +11,7 @@ import n0s1.clients.linear_graphql_client as linear_graphql_client -class LinearControler(hollow_controller.HollowController): +class LinearController(hollow_controller.HollowController): def __init__(self): super().__init__() self._client = None diff --git a/src/n0s1/controllers/platform_controller.py b/src/n0s1/controllers/platform_controller.py index 186ca4c..609acc5 100644 --- a/src/n0s1/controllers/platform_controller.py +++ b/src/n0s1/controllers/platform_controller.py @@ -26,21 +26,25 @@ def get_platform(self, platform): from . import linear_controller as linear_controller from . import asana_controller as asana_controller from . import wrike_controller as wrike_controller + from . import slack_controller as slack_controller except Exception: import n0s1.controllers.jira_controller as jira_controller import n0s1.controllers.confluence_controller as confluence_controller import n0s1.controllers.linear_controller as linear_controller import n0s1.controllers.asana_controller as asana_controller import n0s1.controllers.wrike_controller as wrike_controller - -factory.register_platform("", jira_controller.JiraControler) -factory.register_platform("jira", jira_controller.JiraControler) -factory.register_platform("jira_scan", jira_controller.JiraControler) -factory.register_platform("confluence", confluence_controller.ConfluenceControler) -factory.register_platform("confluence_scan", confluence_controller.ConfluenceControler) -factory.register_platform("linear", linear_controller.LinearControler) -factory.register_platform("linear_scan", linear_controller.LinearControler) -factory.register_platform("asana", asana_controller.AsanaControler) -factory.register_platform("asana_scan", asana_controller.AsanaControler) -factory.register_platform("wrike", wrike_controller.WrikeControler) -factory.register_platform("wrike_scan", wrike_controller.WrikeControler) + import n0s1.controllers.slack_controller as slack_controller + +factory.register_platform("", jira_controller.JiraController) +factory.register_platform("jira", jira_controller.JiraController) +factory.register_platform("jira_scan", jira_controller.JiraController) +factory.register_platform("confluence", confluence_controller.ConfluenceController) +factory.register_platform("confluence_scan", confluence_controller.ConfluenceController) +factory.register_platform("linear", linear_controller.LinearController) +factory.register_platform("linear_scan", linear_controller.LinearController) +factory.register_platform("asana", asana_controller.AsanaController) +factory.register_platform("asana_scan", asana_controller.AsanaController) +factory.register_platform("wrike", wrike_controller.WrikeController) +factory.register_platform("wrike_scan", wrike_controller.WrikeController) +factory.register_platform("slack", slack_controller.SlackController) +factory.register_platform("slack_scan", slack_controller.SlackController) diff --git a/src/n0s1/controllers/slack_controller.py b/src/n0s1/controllers/slack_controller.py new file mode 100644 index 0000000..cd8cde1 --- /dev/null +++ b/src/n0s1/controllers/slack_controller.py @@ -0,0 +1,173 @@ +import datetime +import logging +import re +import time + +try: + from . import hollow_controller as hollow_controller +except Exception: + import n0s1.controllers.hollow_controller as hollow_controller + + +class SlackController(hollow_controller.HollowController): + def __init__(self): + super().__init__() + self._client = None + + def set_config(self, config): + from slack_sdk import WebClient + from slack_sdk.errors import SlackApiError + TOKEN = config.get("token", "") + self._client = WebClient(token=TOKEN) + return self.is_connected() + + def get_name(self): + return "Slack" + + def is_connected(self): + if user := self._client.auth_test(): + self.log_message(f"Logged to Slack as {user}") + else: + self.log_message(f"Unable to connect to Slack. Check your credentials.", logging.ERROR) + return False + return True + + def get_data(self, include_coments=False, limit=None): + max_day_range = 365 * 100 + range_days = 1 + now = datetime.datetime.now() + + # Slack query by timestamp works like "greater than >" and "less than <" operators as opposed to ">=" and "<=". + # If you want to pull messages from 2024-07-14 you have to provide the following query: after:2024-07-13 before:2024-07-15 + # Notice that the messages from the starting date (after:2024-07-13) and the end date (before:2024-07-15) are not included to the query results + end_day = now + datetime.timedelta(days=1) + start_day = now - datetime.timedelta(days=range_days) + + start_day_str = start_day.strftime("%Y-%m-%d") + end_day_str = end_day.strftime("%Y-%m-%d") + + query = f"after:{start_day_str} before:{end_day_str}" + days_counter = 0 + while days_counter < max_day_range: + messages = self.run_slack_query(query) + for m in messages: + len_messages = len(m) + if len_messages <= 0: + range_days = range_days * 2 + else: + range_days = 1 + for item in m: + message = item.get("text", "") + iid = item.get("iid", "") + url = item.get("permalink", "") + ticket = self.pack_data(message, item, url, iid) + yield ticket + + end_day = start_day + datetime.timedelta(days=1) + start_day = start_day - datetime.timedelta(days=range_days) + start_day_str = start_day.strftime("%Y-%m-%d") + end_day_str = end_day.strftime("%Y-%m-%d") + query = f"after:{start_day_str} before:{end_day_str}" + days_counter += range_days + + def post_comment(self, issue, comment): + from slack_sdk.errors import SlackApiError + try: + channel_id, thread_ts = self.extract_channel_id_and_ts(issue) + if comment and len(comment) > 0 and len(channel_id) > 0 and len(thread_ts) > 0: + response = self._client.chat_postMessage( + channel=channel_id, + text=comment, + thread_ts=thread_ts, + unfurl_links=False + ) + + self.log_message(f"Message sent successfully") + response_ts = response.get("ts", "") + self.log_message(f"Thread Timestamp: {response_ts}") + + except SlackApiError as e: + error_message = e.response.get("error", "") + self.log_message(f"Error sending message: {error_message}", logging.ERROR) + + def pack_data(self, message, raw_data, url, iid): + channel_id = raw_data.get("channel", {}).get("id", "") + channel_name = raw_data.get("channel", {}).get("name", "") + is_channel = raw_data.get("channel", {}).get("is_channel", "") + timestamp = raw_data.get("ts", "") + slack_type = raw_data.get("type", "") + ticket_data = { + "ticket": { + "message": { + "name": "message", + "data": message, + "data_type": "str" + }, + }, + "url": url, + "issue_id": url, + "raw_data": { + "iid": iid, + "channel_name": channel_name, + "channel_id": channel_id, + "is_channel": is_channel, + "timestamp": timestamp, + "slack_type": slack_type + } + } + return ticket_data + + def search_with_rate_limit(self, query, sort, cursor): + from slack_sdk.errors import SlackApiError + response = None + try: + response = self._client.search_messages(query=query, sort=sort, cursor=cursor) + except SlackApiError as ex: + message = str(ex) + f" client.search_messages()" + self.log_message(message, logging.WARNING) + retry_after = ex.response.headers.get("Retry-After", "") + if len(retry_after) <= 0: + retry_after = ex.response.headers.get("retry-after", "") + if len(retry_after) > 0: + retry_after = int(retry_after) + else: + retry_after = 30 + retry_after += 5 + self.log_message(f"Rate limit reached! Retrying after [{retry_after}] seconds...", logging.WARNING) + time.sleep(retry_after) + response = self.search_with_rate_limit(query, sort, cursor) + + except Exception as ex: + message = str(ex) + f" client.search_messages()" + self.log_message(message, logging.ERROR) + + return response + + def run_slack_query(self, query): + cursor = "" + self.log_message(f"Scanning Slack messages: [{query}]...") + time.sleep(0.2) + if response := self.search_with_rate_limit(query=query, sort="timestamp", cursor="*"): + messages = response.get("messages", {}).get("matches", []) + cursor = response.get("messages", {}).get("pagination", {}).get("next_cursor", "") + yield messages + + while len(cursor) > 0: + cursor = "" + time.sleep(0.2) + if response := self.search_with_rate_limit(query=query, sort="timestamp", cursor=cursor): + messages = response.get("messages", {}).get("matches", []) + cursor = response.get("messages", {}).get("pagination", {}).get("next_cursor", "") + yield messages + + def extract_channel_id_and_ts(self, link): + # Extract the channel ID and message timestamp from the link + match = re.search(r'archives/([^/]+)/p(\d+)', link) + if match: + channel_id = match.group(1) + message_ts = f"{match.group(2)[:10]}.{match.group(2)[10:]}" + return channel_id, message_ts + else: + self.log_message("Invalid Slack link format", logging.ERROR) + + return "", "" diff --git a/src/n0s1/controllers/wrike_controller.py b/src/n0s1/controllers/wrike_controller.py index f25bd05..091cb46 100644 --- a/src/n0s1/controllers/wrike_controller.py +++ b/src/n0s1/controllers/wrike_controller.py @@ -8,7 +8,7 @@ except Exception: import n0s1.controllers.hollow_controller as hollow_controller -class WrikeControler(hollow_controller.HollowController): +class WrikeController(hollow_controller.HollowController): def __init__(self): super().__init__() self._client = None diff --git a/src/n0s1/n0s1.py b/src/n0s1/n0s1.py index ed6d669..d08d9b8 100755 --- a/src/n0s1/n0s1.py +++ b/src/n0s1/n0s1.py @@ -185,6 +185,17 @@ def init_argparse() -> argparse.ArgumentParser: help="Subcommands", dest="command", metavar="COMMAND" ) + slack_scan_parser = subparsers.add_parser( + "slack_scan", help="Scan Slack messages", parents=[parent_parser] + ) + slack_scan_parser.add_argument( + "--api-key", + dest="api_key", + nargs="?", + type=str, + help="Slack token with OAuth scope: search:read, users:read, chat:write. Ref: https://api.slack.com/tutorials/tracks/getting-a-token" + ) + asana_scan_parser = subparsers.add_parser( "asana_scan", help="Scan Asana tasks", parents=[parent_parser] ) @@ -354,7 +365,7 @@ def report_leaked_secret(scan_text_result, controller): post_comment = scan_text_result.get("scan_arguments", {}).get("post_comment", False) show_matched_secret_on_logs = scan_text_result.get("scan_arguments", {}).get("show_matched_secret_on_logs", False) - finding_info = "Platform:[{platform}] Field:[ticket {field}] ID:[{regex_config_id}] Description:[{regex_config_description}] Regex: {regex}\n############## Sanitized Secret Leak ##############\n {leak}\n############## Sanitized Secret Leak ##############" + finding_info = "Platform:[{platform}] Field:[{field}] ID:[{regex_config_id}] Description:[{regex_config_description}] Regex: {regex}\n############## Sanitized Secret Leak ##############\n {leak}\n############## Sanitized Secret Leak ##############" finding_info = finding_info.format(regex_config_id=regex_id, regex_config_description=regex_description, regex=regex, platform=platform, field=field, leak=sanitized_secret) @@ -384,6 +395,9 @@ def report_leaked_secret(scan_text_result, controller): comment_template += f"\n{variable}: {{{variable}}}" comment = comment_template.format(finding_info=finding_info, bot_name=bot_name, secret_manager=secret_manager, contact_help=contact_help, label=label) + if controller.get_name().lower() == "Slack".lower(): + comment = comment + f"\nLeak source: {url}" + return controller.post_comment(issue_id, comment) return True @@ -436,11 +450,11 @@ def scan(regex_config, controller, scan_arguments): data = item.get("data", None) data_type = item.get("data_type", None) if data_type and data_type.lower() == "str".lower(): - if data: + if data and data.lower().find(label.lower()) == -1: scan_text_and_report_leaks(controller, data, name, regex_config, scan_arguments, ticket) elif data_type: for item_data in data: - if item_data: + if item_data and item_data.lower().find(label.lower()) == -1: scan_text_and_report_leaks(controller, item_data, name, regex_config, scan_arguments, ticket) @@ -497,7 +511,7 @@ def main(callback=None): controller = controller_factory.get_platform(command) controller.set_log_message_callback(callback) - controler_config = {} + controller_config = {} if args.timeout and len(args.timeout) > 0: timeout = int(args.timeout) @@ -514,9 +528,9 @@ def main(callback=None): else: insecure = cfg.get("general_params", {}).get("insecure", False) - controler_config["timeout"] = timeout - controler_config["limit"] = limit - controler_config["insecure"] = insecure + controller_config["timeout"] = timeout + controller_config["limit"] = limit + controller_config["insecure"] = insecure TOKEN = None SERVER = None @@ -525,19 +539,25 @@ def main(callback=None): TOKEN = os.getenv("LINEAR_TOKEN") if args.api_key and len(args.api_key) > 0: TOKEN = args.api_key - controler_config["token"] = TOKEN + controller_config["token"] = TOKEN + + elif command == "slack_scan": + TOKEN = os.getenv("SLACK_TOKEN") + if args.api_key and len(args.api_key) > 0: + TOKEN = args.api_key + controller_config["token"] = TOKEN elif command == "asana_scan": TOKEN = os.getenv("ASANA_TOKEN") if args.api_key and len(args.api_key) > 0: TOKEN = args.api_key - controler_config["token"] = TOKEN + controller_config["token"] = TOKEN elif command == "wrike_scan": TOKEN = os.getenv("WRIKE_TOKEN") if args.api_key and len(args.api_key) > 0: TOKEN = args.api_key - controler_config["token"] = TOKEN + controller_config["token"] = TOKEN elif command == "jira_scan": SERVER = os.getenv("JIRA_SERVER") @@ -549,9 +569,9 @@ def main(callback=None): EMAIL = args.email if args.api_key and len(args.api_key) > 0: TOKEN = args.api_key - controler_config["server"] = SERVER - controler_config["email"] = EMAIL - controler_config["token"] = TOKEN + controller_config["server"] = SERVER + controller_config["email"] = EMAIL + controller_config["token"] = TOKEN elif command == "confluence_scan": SERVER = os.getenv("CONFLUENCE_SERVER") @@ -569,9 +589,9 @@ def main(callback=None): EMAIL = args.email if args.api_key and len(args.api_key) > 0: TOKEN = args.api_key - controler_config["server"] = SERVER - controler_config["email"] = EMAIL - controler_config["token"] = TOKEN + controller_config["server"] = SERVER + controller_config["email"] = EMAIL + controller_config["token"] = TOKEN else: parser.print_help() return @@ -588,7 +608,7 @@ def main(callback=None): message += f" {TOKEN}" log_message(message) - if not controller.set_config(controler_config): + if not controller.set_config(controller_config): sys.exit(-1) if args.post_comment: