diff --git a/canarytokens/channel_output_email.py b/canarytokens/channel_output_email.py index 97c6b9de9..704d62ff2 100644 --- a/canarytokens/channel_output_email.py +++ b/canarytokens/channel_output_email.py @@ -27,8 +27,11 @@ from canarytokens.models import ( AnyTokenHit, AnyTokenExposedHit, + Memo, + TokenExposedHit, readable_token_type_names, TokenAlertDetails, + TokenExposedDetails, token_types_with_article_an, TokenTypes, ) @@ -71,7 +74,7 @@ def __init__( status: EmailResponseStatuses, canarydrop: Canarydrop, message_id: str, - alert_details: TokenAlertDetails, + alert_details: Union[TokenAlertDetails, TokenExposedDetails], max_alert_failures: int, ): self.status = status @@ -92,9 +95,14 @@ def handle_ignored(self): self.canarydrop.disable_alert_email() def handle_sent(self): + time = ( + self.alert_details.time + if isinstance(self.alert_details, TokenAlertDetails) + else self.alert_details.exposed_time + ) queries.remove_mail_from_to_send_status( token=self.alert_details.token, - time=self.alert_details.time, + time=time, ) queries.put_mail_on_sent_queue( mail_key=self.message_id, @@ -283,6 +291,29 @@ def __init__( name=name, ) + @staticmethod + def format_token_exposed_html( + details: TokenExposedDetails, + template_path: Path, + ): + """Returns a string containing an incident report in HTML, + suitable for emailing""" + readable_type = readable_token_type_names[details.token_type] + BasicDetails = details.dict() + BasicDetails["readable_type"] = readable_type + BasicDetails["token_type"] = details.token_type.value + BasicDetails["memo"] = details.memo + BasicDetails["key_id"] = details.key_id + BasicDetails["time_ymd"] = details.time_ymd + BasicDetails["time_hm"] = details.time_hm + + rendered_html = Template(template_path.read_text()).render( + BasicDetails=BasicDetails, + ManageLink=details.manage_url, + HistoryLink=details.history_url, + ) + return minify_html.minify(rendered_html) + @staticmethod def format_report_html( details: TokenAlertDetails, @@ -350,32 +381,50 @@ def format_report_html( return minify_html.minify(rendered_html) @staticmethod - def format_report_text(details: TokenAlertDetails, body_length: int = 999999999): + def format_report_text(details: TokenAlertDetails): """Returns a string containing an incident report in text, suitable for emailing""" - if body_length <= 140: - body = """Canarydrop@{time} via {channel_name}: """.format( - channel_name=details.channel, time=details.time - ) - capacity = 140 - len(body) - body += details.memo[:capacity] - else: - additional_data = "\n" + "\n".join( - f"{k}: {v}" for k, v in details.additional_data.items() - ) - body = textwrap.dedent( - f""" - One of your canarydrops was triggered. - Channel: {details.channel} - Time : {details.time} - Memo : {details.memo}{additional_data} - Manage your settings for this Canarydrop: - {details.manage_url} - """ - ).strip() + additional_data = "\n" + "\n".join( + f"{k}: {v}" for k, v in details.additional_data.items() + ) + body = textwrap.dedent( + f""" + One of your canarydrops was triggered. + Channel: {details.channel} + Time : {details.time} + Memo : {details.memo}{additional_data} + Manage your settings for this Canarydrop: + {details.manage_url} + """ + ).strip() + return body + + @staticmethod + def format_token_exposed_text(details: TokenExposedDetails): + """Returns a string containing an incident report in text, + suitable for emailing""" + body = textwrap.dedent( + f""" + One of your Canarytokens was exposed on the internet. + Location: {details.public_location or "unknown"} + Time : {details.exposed_time} + Memo : {details.memo} + Manage your settings for this Canarydrop: + {details.manage_url} + """ + ).strip() return body + @staticmethod + def format_token_exposed_intro(details: TokenExposedDetails): + article = "An" if details.token_type in token_types_with_article_an else "A" + readable_type = readable_token_type_names[details.token_type] + intro = ( + f"{article} {readable_type} Canarytoken has been exposed on the internet." + ) + return intro + @staticmethod def format_report_intro(details: TokenAlertDetails): details.channel @@ -421,28 +470,56 @@ def do_send_alert( canarydrop: Canarydrop, token_hit: Union[AnyTokenHit, AnyTokenExposedHit], ): - alert_details = input_channel.gather_alert_details( - canarydrop=canarydrop, - host=self.switchboard_settings.PUBLIC_DOMAIN, - protocol=self.switchboard_scheme, - ) - # + if isinstance(token_hit, TokenExposedHit): + details = TokenExposedDetails( + token_type=token_hit.token_type, + token=canarydrop.canarytoken.value(), + key_id=canarydrop.aws_access_key_id, + memo=Memo(canarydrop.memo), + public_location=token_hit.public_location, + exposed_time=token_hit.time_of_hit, + manage_url=canarydrop.build_manage_url( + self.switchboard_scheme, self.hostname + ), + public_domain=self.hostname, + ) + else: + details = input_channel.gather_alert_details( + canarydrop=canarydrop, + host=self.switchboard_settings.PUBLIC_DOMAIN, + protocol=self.switchboard_scheme, + ) + queries.add_mail_to_send_status( recipient=canarydrop.alert_email_recipient, - details=alert_details, - ) - email_content_html = EmailOutputChannel.format_report_html( - alert_details, - Path( - f"{self.switchboard_settings.TEMPLATES_PATH}/emails/notification.html" - ), + details=details, ) + + if isinstance(details, TokenExposedDetails): + email_content_html = EmailOutputChannel.format_token_exposed_html( + details, + Path( + f"{self.switchboard_settings.TEMPLATES_PATH}/emails/notification_token_exposed.html" + ), + ) + email_content_text = EmailOutputChannel.format_token_exposed_text(details) + email_subject = "Canarytoken Exposed" + else: + email_content_html = EmailOutputChannel.format_report_html( + details, + Path( + f"{self.switchboard_settings.TEMPLATES_PATH}/emails/notification.html" + ), + ) + email_content_text = EmailOutputChannel.format_report_text(details) + email_subject = self.email_subject + if self.switchboard_settings.MAILGUN_API_KEY: email_response_status, message_id = mailgun_send( email_address=canarydrop.alert_email_recipient, - email_subject=self.email_subject, + email_subject=email_subject, email_content_html=email_content_html, - email_content_text=EmailOutputChannel.format_report_text(alert_details), + email_content_text=email_content_text, from_email=EmailStr(self.from_email), from_display=self.from_display, api_key=self.switchboard_settings.MAILGUN_API_KEY, @@ -455,7 +532,7 @@ def do_send_alert( email_address=canarydrop.alert_email_recipient, email_content_html=email_content_html, from_email=EmailStr(self.from_email), - email_subject=self.email_subject, + email_subject=email_subject, from_display=self.from_display, sandbox_mode=False, ) @@ -463,8 +540,8 @@ def do_send_alert( email_response_status, message_id = smtp_send( email_address=canarydrop.alert_email_recipient, email_content_html=email_content_html, - email_content_text=EmailOutputChannel.format_report_text(alert_details), - email_subject=self.email_subject, + email_content_text=email_content_text, + email_subject=email_subject, from_email=EmailStr(self.from_email), from_display=self.from_display, smtp_password=self.switchboard_settings.SMTP_PASSWORD, @@ -480,11 +557,11 @@ def do_send_alert( status=email_response_status, canarydrop=canarydrop, message_id=message_id, - alert_details=alert_details, + alert_details=details, max_alert_failures=self.switchboard_settings.MAX_ALERT_FAILURES, ) ) - return alert_details + return details def handle_email_response(self, email_response: EmailResponse): return email_response.handle() @@ -503,72 +580,3 @@ def check_sendgrid_mail_status(api_key: str) -> bool: bool: returns True if mail was processed. False otherwise. """ return - # sg = sendgrid.SendGridAPIClient(api_key=api_key) - # mail_key, alert_details = queries.pop_mail_off_sent_queue() - # # Check using: resp = sg.client.messages._(mail_key).get() - # if False: - # # delivery was not made. - # queries.put_mail_on_sent_queue(mail_key=mail_key, details=alert_details) - # return mail_key is not None - - # def get_basic_details(self,): - - # vars = { 'Description' : self.data['description'], - # 'Channel' : self.data['channel'], - # 'Time' : self.data['time'], - # 'Canarytoken' : self.data['canarytoken'] - # } - - # if 'src_ip' in self.data: - # vars['src_ip'] = self.data['src_ip'] - # vars['SourceIP'] = self.data['src_ip'] - - # if 'useragent' in self.data: - # vars['User-Agent'] = self.data['useragent'] - - # if 'tokentype' in self.data: - # vars['TokenType'] = self.data['tokentype'] - - # if 'referer' in self.data: - # vars['Referer'] = self.data['referer'] - - # if 'location' in self.data: - # try: - # vars['Location'] = self.data['location'].decode('utf-8') - # except Exception: - # vars['Location'] = self.data['location'] - - # if 'log4_shell_computer_name' in self.data: - # vars['Log4JComputerName'] = self.data['log4_shell_computer_name'] - - # return vars - - # def mandrill_send(self, msg=None, canarydrop=None): - # try: - # mandrill_client = mandrill.Mandrill(settings.MANDRILL_API_KEY) - # message = { - # 'auto_html': None, - # 'auto_text': None, - # 'from_email': msg['from_address'], - # 'from_name': msg['from_display'], - # 'text': msg['body'], - # 'html':self.format_report_html(), - # 'subject': msg['subject'], - # 'to': [{'email': canarydrop['alert_email_recipient'], - # 'name': '', - # 'type': 'to'}], - # } - # if settings.DEBUG: - # pprint.pprint(message) - # else: - # result = mandrill_client.messages.send(message=message, - # async=False, - # ip_pool='Main Pool') - # log.info('Sent alert to {recipient} for token {token}'\ - # .format(recipient=canarydrop['alert_email_recipient'], - # token=canarydrop.canarytoken.value())) - - # except mandrill.Error, e: - # # Mandrill errors are thrown as exceptions - # log.error('A mandrill error occurred: %s - %s' % (e.__class__, e)) - # # A mandrill error occurred: - No subaccount exists with the id 'customer-123'.... diff --git a/canarytokens/queries.py b/canarytokens/queries.py index 5befac0b4..f34d9d3fc 100644 --- a/canarytokens/queries.py +++ b/canarytokens/queries.py @@ -587,12 +587,18 @@ def can_send_alert(canarydrop: cand.Canarydrop, alert_limit: int): # raise Exception("Imgur response was unexpected: {resp}".format(resp=resp)) # return resp["data"][imgur_id] def add_mail_to_send_status( - recipient: EmailStr, details: models.TokenAlertDetails + recipient: EmailStr, + details: Union[models.TokenAlertDetails, models.TokenExposedDetails], ) -> int: data = {"recipient": recipient, **details.json_safe_dict()} mail_to_send = json.dumps(data) + time = ( + details.time + if isinstance(details, models.TokenAlertDetails) + else details.exposed_time + ) return DB.get_db().set( - f"{KEY_MAIL_TO_SEND}:{details.token}:{details.time.timestamp()}", mail_to_send + f"{KEY_MAIL_TO_SEND}:{details.token}:{time.timestamp()}", mail_to_send ) @@ -610,17 +616,28 @@ def get_all_mails_in_send_status( def remove_mail_from_to_send_status( token: str, time: datetime.datetime -) -> tuple[Optional[list[EmailStr]], Optional[models.TokenAlertDetails]]: +) -> tuple[ + Optional[list[EmailStr]], + Optional[Union[models.TokenAlertDetails, models.TokenExposedDetails]], +]: item = DB.get_db().getdel(f"{KEY_MAIL_TO_SEND}:{token}:{time.timestamp()}") if item is None: log.info(f"No mail at key: {KEY_MAIL_TO_SEND}:{token}:{time.timestamp()}") return None, None + data = json.loads(item) recipient = EmailStr(data.pop("recipient")) - return recipient, models.TokenAlertDetails(**data) + details = ( + models.TokenExposedDetails(**data) + if "public_location" in data + else models.TokenAlertDetails(**data) + ) + return recipient, details -def put_mail_on_sent_queue(mail_key: str, details: models.TokenAlertDetails) -> int: +def put_mail_on_sent_queue( + mail_key: str, details: Union[models.TokenAlertDetails, models.TokenExposedDetails] +) -> int: sent_mail = json.dumps({"mail_key": mail_key, **details.json_safe_dict()}) return DB.get_db().lpush(KEY_SENT_MAIL_QUEUE, sent_mail) diff --git a/templates/emails/notification_token_exposed.html b/templates/emails/notification_token_exposed.html new file mode 100644 index 000000000..135bceffe --- /dev/null +++ b/templates/emails/notification_token_exposed.html @@ -0,0 +1,618 @@ + + + + + Canarytoken exposed! + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + +
+
+ +
+
+ + + +
+ + + + + + +
+ + + +
+ + + + + + +
+ + + + + + +
+ Canarytokens +
+
+
+ +
+
+ +
+ + + + + + +
+ + + +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + + + + + + + +
+ + + + + + +
+ Canarytokens +
+
+
Your Canarytoken was exposed publicly
+
+
One of your {{BasicDetails['readable_type']}} Canarytokens has been found on the {% if BasicDetails['public_location'] %} internet. {% else %} internet. {% endif %} A publicly exposed token will provide very low quality alerts. We recommend that you disable and replace this token on private infrastructure.
+
+
+
+ +
+
+ +
+
+ + + +
+ + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ + + + + +
+ + +

Reminder

+

{{BasicDetails['memo'] | e}}

+
+
+
+
+ +
+
+ +
+
+ + + +
+ + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ + + + + +
+ + +

Key found here

+

+ {{ BasicDetails['public_location'] }} +

+
+
+
+
+ +
+
+ +
+
+ + + +
+ + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ + + + + +
+ + +

Key ID

+

{{ BasicDetails['key_id'] | e}}

+
+
+
+
+ +
+
+ +
+
+ + + +
+ + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ + + + + + +
+ + +

Date

+

{{BasicDetails['time_ymd']}}

+
+

Time

+

{{BasicDetails['time_hm']}} UTC

+
+
+
+
+ +
+
+ +
+
+ + + +
+ + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + +
+ + + + + + +
+ Manage Alert +
+
+
+ +
+
+ +
+
+ + + +
+ + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + +
+
More info on this Canarytoken? Click here
+
+
+ +
+
+ +
+
+ + + +
+ + + + + + +
+ +
+
+ +
+ + + + + + +
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + +
+ +
+ + + + + + + + + +
+
Some of the best security teams in the world run Thinkst Canary.
+
+ +
+
+ +
+
+ +
+
+ +
+ + + + + + +
+ +
+
+ +
+ + + diff --git a/templates/emails/notification_token_exposed.mjml b/templates/emails/notification_token_exposed.mjml new file mode 100644 index 000000000..d7362d8c1 --- /dev/null +++ b/templates/emails/notification_token_exposed.mjml @@ -0,0 +1,211 @@ + + + + Canarytoken exposed! + + + + + + .center-image { + display: block; + margin: 0 auto; + } + + + + + + + + + + + + + + + + + + + + + + + + + + + Your Canarytoken was exposed publicly + + One of your {{BasicDetails['readable_type']}} + Canarytokens has been found on the + {% if BasicDetails['public_location'] %} + internet. + {% else %} + internet. + {% endif %} + A publicly exposed token will provide very low quality alerts. We recommend that you disable and replace this token on private infrastructure. + + + + + + + + + + + + + + + + + +

Reminder

+

{{BasicDetails['memo'] | e}}

+ + + +
+
+
+ +
+ + + + + + + + + + + + +

Key found here

+

+ {{ BasicDetails['public_location'] }} +

+ + + +
+
+
+
+ + + + + + + + + + + + +

Key ID

+

{{ BasicDetails['key_id'] | e}}

+ + + +
+
+
+
+ + + + + + + + + + + + +

Date

+

{{BasicDetails['time_ymd']}}

+ + +

Time

+

{{BasicDetails['time_hm']}} UTC

+ + + +
+
+
+ +
+ + + + + + + Manage Alert + + + + + + + + + + More info on this Canarytoken? Click here + + + + + + + + + + + + + + Some of the best security teams in the world run Thinkst Canary. + + + Find out why + + + + + + + + +
+
\ No newline at end of file