├── test ├── __init__.py ├── test_terraform.py ├── test_lambda_converter.py └── test_lambda_slack.py ├── lambda_converter ├── __init__.py └── lambda_function.py ├── lambda_slack ├── __init__.py └── lambda_function.py ├── README.md ├── outputs.tf ├── requirements.txt ├── variables.tf ├── .gitignore └── main.tf /test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lambda_converter/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lambda_slack/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # stdf-alert-pipeline 2 | Standard alerting pipeline for stdf format 3 | -------------------------------------------------------------------------------- /outputs.tf: -------------------------------------------------------------------------------- 1 | output "incoming_sns_arn" { 2 | value = aws_sns_topic.incoming.arn 3 | } 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | https://github.com/foursquare-oss/terraform_validate/archive/master.zip 2 | boto3 3 | -------------------------------------------------------------------------------- /variables.tf: -------------------------------------------------------------------------------- 1 | variable "name" { 2 | description = "The name of the pipeline" 3 | } 4 | 5 | variable "allowed_accounts" { 6 | description = "The account numbers allowed to post to the pipeline" 7 | } 8 | 9 | variable "fallback_sns" { 10 | description = "The SNS topic arn to post any lambda failures to" 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # terraform stuff 2 | **/.terraform 3 | *.tfstate 4 | *.tfstate.* 5 | crash.log 6 | 7 | # IDEs 8 | .idea/** 9 | .vscode 10 | .vsls.json 11 | **/*.code-workspace 12 | 13 | # Mac stuff 14 | **/.DS_Store 15 | .DS_Store 16 | 17 | # Terraform lambda packing stuff 18 | lambda*.zip 19 | 20 | # Python stuff 21 | __pycache__ 22 | *.pyc 23 | -------------------------------------------------------------------------------- /lambda_converter/lambda_function.py: -------------------------------------------------------------------------------- 1 | import json 2 | import boto3 3 | import os 4 | import logging 5 | import datetime 6 | 7 | 8 | def error_if_not_exactly_one_record(event): 9 | number_of_records = len(event['Records']) 10 | if (number_of_records != 1): 11 | raise Exception( 12 | f'Received SNS message with {number_of_records} records. This should never happen: AWS states all lambda SNS messages will always have exactly one record.') 13 | 14 | 15 | def get_subject_and_message(event): 16 | subject = event['Records'][0]['Sns']['Subject'] 17 | message = event['Records'][0]['Sns']['Message'] 18 | return subject, message 19 | 20 | 21 | def parse_metric_to_stdf(message): 22 | parsed_message = json.loads(message) 23 | aws_time_format = '%Y-%m-%dT%H:%M:%S.%f%z' 24 | timestamp = int(datetime.datetime.strptime(parsed_message['StateChangeTime'], aws_time_format).timestamp() * 1000) 25 | alarm_name = parsed_message['AlarmName'] 26 | stdf_message = { 27 | 'payload': { 28 | 'title': alarm_name, 29 | 'description': parsed_message['AlarmDescription'], 30 | 'raw_data': parsed_message['NewStateReason'] 31 | }, 32 | 'meta': { 33 | 'timestamp': timestamp, 34 | 'source': { 35 | 'provider': 'AWS', 36 | 'account_id': parsed_message['AWSAccountId'], 37 | 'region': parsed_message['Region'], 38 | 'service': 'CloudWatch', 39 | 'app_name': 'Metric Alarm' 40 | }, 41 | 'urls': [{ 42 | 'url': f'https://console.aws.amazon.com/cloudwatch/home#alarmsV2:alarm/{alarm_name}', 43 | 'text': 'Show alarm details' 44 | }] 45 | }, 46 | 'stdf_version': 2, 47 | } 48 | 49 | return json.dumps(stdf_message) 50 | 51 | 52 | def post_stdf_string_to_sns(message): 53 | sns = boto3.client('sns') 54 | subject = f'stdf: {json.loads(message)["payload"]["title"]}' 55 | sns.publish( 56 | TopicArn=os.getenv('OUTGOING_SNS_TOPIC'), 57 | Subject=(subject[:88] + '[TRUNCATED]') if len(subject) > 99 else subject, 58 | Message=message 59 | ) 60 | 61 | 62 | def post_unrecognized_to_sns_and_warn(message, event): 63 | log = logging.getLogger('Unrecognized Message Format') 64 | log.warn(event) 65 | sns = boto3.client('sns') 66 | sns.publish( 67 | TopicArn=os.getenv('OUTGOING_SNS_TOPIC'), 68 | Subject='unrecognizedNonStdfMessage', 69 | Message=message 70 | ) 71 | 72 | 73 | def log_event(event): 74 | print(event) 75 | 76 | 77 | def lambda_handler(event, context): 78 | log_event(event) 79 | error_if_not_exactly_one_record(event) 80 | subject, message = get_subject_and_message(event) 81 | 82 | if subject == 'stdfMessage': 83 | post_stdf_string_to_sns(message) 84 | elif subject.startswith('ALARM: '): 85 | stdf_message = parse_metric_to_stdf(message) 86 | post_stdf_string_to_sns(stdf_message) 87 | else: 88 | post_unrecognized_to_sns_and_warn(message, event) 89 | -------------------------------------------------------------------------------- /lambda_slack/lambda_function.py: -------------------------------------------------------------------------------- 1 | import json 2 | import http.client 3 | import datetime 4 | import os 5 | import boto3 6 | 7 | def error_if_not_exactly_one_record(event): 8 | number_of_records = len(event['Records']) 9 | if number_of_records != 1: 10 | raise Exception( 11 | f'Received SNS message with {number_of_records} records. This should never happen: AWS states all lambda SNS messages will always have exactly one record.') 12 | 13 | 14 | def get_subject_and_message(event): 15 | subject = event['Records'][0]['Sns']['Subject'] 16 | message = event['Records'][0]['Sns']['Message'] 17 | return subject, message 18 | 19 | 20 | def get_webhook(): 21 | sm = boto3.client('secretsmanager') 22 | return json.loads(sm.get_secret_value(SecretId=os.environ['SLACK_WEBHOOK_SECRET_ID'])['SecretString']) 23 | 24 | 25 | def get_zulu_timestamp(unix_time_milliseconds): 26 | return datetime.datetime.fromtimestamp(unix_time_milliseconds // 1000, tz=datetime.timezone(datetime.timedelta(0))).isoformat().replace('+00:00', 'Z') 27 | 28 | 29 | def format_urls(urls): 30 | formatted_urls = '' 31 | first = True 32 | for url in urls: 33 | if first: 34 | first = False 35 | else: 36 | formatted_urls += ' - ' 37 | formatted_urls += f"<{url['url']}|{url['text']}>" 38 | 39 | return formatted_urls 40 | 41 | 42 | def format_stdf(stdf_message): 43 | stdf_parsed = json.loads(stdf_message) 44 | app_name = stdf_parsed['meta']['source']['app_name'] 45 | alert_title = stdf_parsed['payload']['title'] 46 | description = stdf_parsed['payload']['description'] 47 | log_line = stdf_parsed['payload']['raw_data'] 48 | provider = stdf_parsed['meta']['source']['provider'] 49 | service_name = stdf_parsed['meta']['source']['service'] 50 | account_id = stdf_parsed['meta']['source']['account_id'] 51 | zulu_timestamp = get_zulu_timestamp(stdf_parsed['meta']['timestamp']) 52 | 53 | divider = {'type': 'divider'} 54 | header_section = { 55 | 'type': 'section', 56 | 'text': { 57 | 'type': 'mrkdwn', 58 | 'text': f'*{app_name}*: *{alert_title}*' 59 | } 60 | } 61 | description_section = { 62 | 'type': 'section', 63 | 'text': { 64 | 'type': 'mrkdwn', 65 | 'text': description 66 | } 67 | } 68 | log_line_section = { 69 | 'type': 'section', 70 | 'text': { 71 | 'type': 'mrkdwn', 72 | 'text': f'```{log_line}```' 73 | } 74 | } 75 | meta_section = { 76 | 'type': 'section', 77 | 'fields': [ 78 | { 79 | 'type': 'mrkdwn', 80 | 'text': f'*Source:* {provider} {service_name}' 81 | }, 82 | { 83 | 'type': 'mrkdwn', 84 | 'text': f'*Account id:* {account_id}' 85 | }, 86 | { 87 | 'type': 'mrkdwn', 88 | 'text': f'*Timestamp*: {zulu_timestamp}' 89 | }, 90 | { 91 | 'type': 'mrkdwn', 92 | 'text': '*Region:* us-east-1' 93 | } 94 | ] 95 | } 96 | blocks = [ 97 | header_section, 98 | description_section, 99 | log_line_section, 100 | divider, 101 | meta_section, 102 | divider, 103 | ] 104 | 105 | urls = stdf_parsed['meta'].get('urls') 106 | if urls is not None: 107 | links = format_urls(urls) 108 | links_section = { 109 | 'type': 'section', 110 | 'text': { 111 | 'type': 'mrkdwn', 112 | 'text': links 113 | } 114 | } 115 | blocks.append(links_section) 116 | 117 | event_id = stdf_parsed['meta']['source'].get('event_id') 118 | if event_id is not None: 119 | event_id_section = { 120 | 'type': 'context', 121 | 'elements': [ 122 | { 123 | 'type': 'mrkdwn', 124 | 'text': f'*Event id*: {event_id}' 125 | } 126 | ] 127 | } 128 | blocks.append(event_id_section) 129 | 130 | return {'blocks': blocks} 131 | 132 | 133 | def format_fallback(message, fallback_reason): 134 | return {'text': f'{fallback_reason}. Failed to format the message. Using fallback mode!\n{message}'} 135 | 136 | 137 | def post_to_slack(webhook, slack_message): 138 | dumped_message = json.dumps(slack_message) 139 | conn = http.client.HTTPSConnection(webhook['host'], 443) 140 | conn.request( 141 | 'POST', 142 | webhook['path'], 143 | body=dumped_message, 144 | headers={ 145 | 'Content-Type': 'application/json' 146 | }) 147 | 148 | status = conn.getresponse().status 149 | 150 | if status != http.HTTPStatus.OK: 151 | raise Exception(f"Request to slack returned an error {status}") 152 | 153 | 154 | def lambda_handler(event, context): 155 | error_if_not_exactly_one_record(event) 156 | subject, message = get_subject_and_message(event) 157 | webhook = get_webhook() 158 | 159 | if subject != 'stdfMessage' and not subject.startswith('stdf: '): 160 | fallback_slack_message = format_fallback(message, fallback_reason=subject) 161 | post_to_slack(webhook, fallback_slack_message) 162 | return 163 | 164 | try: 165 | slack_message = format_stdf(message) 166 | post_to_slack(webhook, slack_message) 167 | except ValueError as error: 168 | fallback_slack_message = format_fallback(message, fallback_reason=str(error)) 169 | post_to_slack(webhook, fallback_slack_message) 170 | -------------------------------------------------------------------------------- /main.tf: -------------------------------------------------------------------------------- 1 | resource "aws_sns_topic" "incoming" { 2 | name = "stdf-alert-pipeline-${var.name}-incoming" 3 | } 4 | 5 | resource "aws_sns_topic_policy" "incoming_cross_account" { 6 | arn = aws_sns_topic.incoming.arn 7 | policy = data.aws_iam_policy_document.incoming_sns_cross_account.json 8 | } 9 | 10 | data "aws_iam_policy_document" "incoming_sns_cross_account" { 11 | statement { 12 | sid = "CrossAccount" 13 | actions = [ 14 | "SNS:Publish" 15 | ] 16 | resources = [ 17 | aws_sns_topic.incoming.arn 18 | ] 19 | effect = "Allow" 20 | principals { 21 | type = "AWS" 22 | identifiers = var.allowed_accounts 23 | } 24 | } 25 | 26 | statement { 27 | sid = "CrossAccountWithCloudWatch" 28 | actions = [ 29 | "SNS:Publish" 30 | ] 31 | resources = [ 32 | aws_sns_topic.incoming.arn 33 | ] 34 | effect = "Allow" 35 | principals { 36 | type = "AWS" 37 | identifiers = [ 38 | "*" 39 | ] 40 | } 41 | condition { 42 | test = "StringEquals" 43 | variable = "AWS:SourceOwner" 44 | values = var.allowed_accounts 45 | } 46 | } 47 | } 48 | 49 | resource "aws_sns_topic_subscription" "incoming" { 50 | topic_arn = aws_sns_topic.incoming.arn 51 | protocol = "lambda" 52 | endpoint = aws_lambda_function.converter_lambda.arn 53 | } 54 | 55 | resource "aws_lambda_permission" "converter_lambda_with_sns" { 56 | statement_id = "AllowExecutionFromSNS" 57 | action = "lambda:InvokeFunction" 58 | function_name = aws_lambda_function.converter_lambda.arn 59 | principal = "sns.amazonaws.com" 60 | source_arn = aws_sns_topic.incoming.arn 61 | } 62 | 63 | data "archive_file" "converter_lambda_zip" { 64 | type = "zip" 65 | source_dir = "${path.module}/lambda_converter" 66 | output_path = "${path.module}/lambda_converter.zip" 67 | } 68 | 69 | resource "aws_lambda_function" "converter_lambda" { 70 | filename = "${path.module}/lambda_converter.zip" 71 | source_code_hash = data.archive_file.converter_lambda_zip.output_base64sha256 72 | function_name = "stdf-alert-pipeline-${var.name}-converter" 73 | description = "Converts alerts from various formats into STDF" 74 | handler = "lambda_function.lambda_handler" 75 | runtime = "python3.7" 76 | role = aws_iam_role.converter_lambda.arn 77 | environment { 78 | variables = { 79 | OUTGOING_SNS_TOPIC = aws_sns_topic.fanout.arn 80 | } 81 | } 82 | timeout = 10 83 | memory_size = 128 84 | } 85 | 86 | data "aws_iam_policy" "basic_execution_role_policy" { 87 | arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" 88 | } 89 | 90 | resource "aws_iam_role" "converter_lambda" { 91 | name = "stdf-alert-pipeline-${var.name}-converter" 92 | path = "/" 93 | assume_role_policy = <" 239 | 240 | # Act 241 | actual_formatted_urls = lambda_function.format_urls(expected_urls) 242 | 243 | # Assert 244 | self.assertEqual(actual_formatted_urls, expected_formatted_urls) 245 | 246 | def test_format_urls_2_urls(self): 247 | # Arrange 248 | expected_url_1 = { 249 | 'url': 'http://example.com', 250 | 'text': 'click this' 251 | } 252 | expected_url_2 = { 253 | 'url': 'http://corona.com', 254 | 'text': 'virus' 255 | } 256 | 257 | expected_urls = [expected_url_1, expected_url_2] 258 | expected_formatted_urls = f"<{expected_url_1['url']}|{expected_url_1['text']}> - <{expected_url_2['url']}|{expected_url_2['text']}>" 259 | 260 | # Act 261 | actual_formatted_urls = lambda_function.format_urls(expected_urls) 262 | 263 | # Assert 264 | self.assertEqual(actual_formatted_urls, expected_formatted_urls) 265 | 266 | def test_format_urls_multi_urls(self): 267 | # Arrange 268 | expected_url_1 = { 269 | 'url': 'http://example.com', 270 | 'text': 'click this' 271 | } 272 | expected_url_2 = { 273 | 'url': 'http://corona.com', 274 | 'text': 'virus' 275 | } 276 | expected_url_3 = { 277 | 'url': 'http://nintendo.com', 278 | 'text': 'switch' 279 | } 280 | 281 | expected_urls = [expected_url_1, expected_url_2, expected_url_3] 282 | expected_formatted_urls = f"<{expected_url_1['url']}|{expected_url_1['text']}> - <{expected_url_2['url']}|{expected_url_2['text']}> - <{expected_url_3['url']}|{expected_url_3['text']}>" 283 | 284 | # Act 285 | actual_formatted_urls = lambda_function.format_urls(expected_urls) 286 | 287 | # Assert 288 | self.assertEqual(actual_formatted_urls, expected_formatted_urls) 289 | 290 | 291 | class TestGetZuluTime(unittest.TestCase): 292 | def setUp(self): 293 | importlib.reload(lambda_function) 294 | 295 | def test_get_zulu_timestamp(self): 296 | # Arrange 297 | expected_timestamp_milliseconds = 1582154420 * 1000 298 | expected_zulu_timestamp = '2020-02-19T23:20:20Z' 299 | 300 | # Act 301 | actual_zulu_timestamp = lambda_function.get_zulu_timestamp(expected_timestamp_milliseconds) 302 | 303 | # Assert 304 | self.assertEqual(actual_zulu_timestamp, expected_zulu_timestamp) 305 | 306 | 307 | class TestFormatStdf(unittest.TestCase): 308 | def setUp(self): 309 | importlib.reload(lambda_function) 310 | 311 | @unittest.mock.patch('lambda_slack.lambda_function.get_zulu_timestamp') 312 | @unittest.mock.patch('lambda_slack.lambda_function.format_urls') 313 | def test_format_stdf_happy_path(self, mock_format_urls, mock_get_zulu_timestamp): 314 | # Arrange 315 | expected_event_id = '000000000000000000000000000000000000000000000' 316 | expected_provider = 'AWS' 317 | expected_service_name = 'CloudWatch' 318 | expected_region = 'us-east-1' 319 | expected_account_id = '123456789012' 320 | expected_timestamp_zulu = '2020-02-20T02:20:20Z' 321 | mock_get_zulu_timestamp.return_value = expected_timestamp_zulu 322 | expected_timestamp_milliseconds = 1582154420 * 1000 323 | expected_log_line = '2020-03-26 08:36:27.000000000 0900 http_logs: {\'host\':\'10.112.250.161\',\'user\':null,\'method\':\'POST\',\'path\':\'/saml/SSO\',\'code\':302,\'size\':null,\'referer\':null,\'agent\':null}' 324 | 325 | expected_description = 'Something happened. A lot of things actually so this text is really long. This is a very detailed description innit.' 326 | expected_app_name = 'Jamf' 327 | expected_alert_title = 'BambooHR sync failed' 328 | 329 | expected_url_1 = { 330 | 'url': 'http://example.com', 331 | 'text': 'click this' 332 | } 333 | expected_url_2 = { 334 | 'url': 'http://that.com', 335 | 'text': 'click that' 336 | } 337 | 338 | expected_links = f"<{expected_url_1['url']}|{expected_url_1['text']}> - <{expected_url_2['url']}|{expected_url_2['text']}>" 339 | mock_format_urls.return_value = expected_links 340 | 341 | expected_formatted_message = { 342 | 'blocks': [ 343 | { 344 | 'type': 'section', 345 | 'text': { 346 | 'type': 'mrkdwn', 347 | 'text': f'*{expected_app_name}*: *{expected_alert_title}*' 348 | } 349 | }, 350 | { 351 | 'type': 'section', 352 | 'text': { 353 | 'type': 'mrkdwn', 354 | 'text': expected_description 355 | } 356 | }, 357 | { 358 | 'type': 'section', 359 | 'text': { 360 | 'type': 'mrkdwn', 361 | 'text': f'```{expected_log_line}```' 362 | } 363 | }, 364 | { 365 | 'type': 'divider' 366 | }, 367 | { 368 | 'type': 'section', 369 | 'fields': [ 370 | { 371 | 'type': 'mrkdwn', 372 | 'text': f'*Source:* {expected_provider} {expected_service_name}' 373 | }, 374 | { 375 | 'type': 'mrkdwn', 376 | 'text': f'*Account id:* {expected_account_id}' 377 | }, 378 | { 379 | 'type': 'mrkdwn', 380 | 'text': f'*Timestamp*: {expected_timestamp_zulu}' 381 | }, 382 | { 383 | 'type': 'mrkdwn', 384 | 'text': '*Region:* us-east-1' 385 | } 386 | ] 387 | }, 388 | { 389 | 'type': 'divider' 390 | }, 391 | { 392 | 'type': 'section', 393 | 'text': { 394 | 'type': 'mrkdwn', 395 | 'text': expected_links 396 | } 397 | }, 398 | { 399 | 'type': 'context', 400 | 'elements': [ 401 | { 402 | 'type': 'mrkdwn', 403 | 'text': f'*Event id*: {expected_event_id}' 404 | } 405 | ] 406 | } 407 | ] 408 | } 409 | 410 | expected_urls = [ 411 | expected_url_1, 412 | expected_url_2 413 | ] 414 | 415 | expected_stdf_message = { 416 | 'payload': { 417 | 'title': expected_alert_title, 418 | 'description': expected_description, 419 | 'raw_data': expected_log_line 420 | }, 421 | 'meta': { 422 | 'timestamp': expected_timestamp_milliseconds, 423 | 'source': { 424 | 'provider': expected_provider, 425 | 'account_id': expected_account_id, 426 | 'region': expected_region, 427 | 'service': expected_service_name, 428 | 'event_id': expected_event_id, 429 | 'app_name': expected_app_name 430 | }, 431 | 'urls': expected_urls 432 | }, 433 | 'stdf_version': 2, 434 | } 435 | 436 | expected_stdf_message_raw = json.dumps(expected_stdf_message) 437 | 438 | # Act 439 | actual_formatted_message = lambda_function.format_stdf(expected_stdf_message_raw) 440 | 441 | # Assert 442 | self.assertEqual(actual_formatted_message, expected_formatted_message) 443 | mock_format_urls.assert_called_once_with(expected_urls) 444 | mock_get_zulu_timestamp.assert_called_once_with(expected_timestamp_milliseconds) 445 | 446 | @unittest.mock.patch('lambda_slack.lambda_function.get_zulu_timestamp') 447 | @unittest.mock.patch('lambda_slack.lambda_function.format_urls') 448 | def test_format_stdf_no_urls_provided(self, mock_format_urls, mock_get_zulu_timestamp): 449 | # Arrange 450 | expected_event_id = '000000000000000000000000000000000000000000000' 451 | expected_provider = 'AWS' 452 | expected_service_name = 'CloudWatch' 453 | expected_region = 'us-east-1' 454 | expected_account_id = '123456789012' 455 | expected_timestamp_zulu = '2020-02-20T02:20:20Z' 456 | mock_get_zulu_timestamp.return_value = expected_timestamp_zulu 457 | expected_timestamp_milliseconds = 1582154420 * 1000 458 | expected_log_line = '2020-03-26 08:36:27.000000000 0900 http_logs: {\'host\':\'10.112.250.161\',\'user\':null,\'method\':\'POST\',\'path\':\'/saml/SSO\',\'code\':302,\'size\':null,\'referer\':null,\'agent\':null}' 459 | 460 | expected_description = 'Something happened. A lot of things actually so this text is really long. This is a very detailed description innit.' 461 | expected_app_name = 'Jamf' 462 | expected_alert_title = 'BambooHR sync failed' 463 | 464 | expected_formatted_message = { 465 | 'blocks': [ 466 | { 467 | 'type': 'section', 468 | 'text': { 469 | 'type': 'mrkdwn', 470 | 'text': f'*{expected_app_name}*: *{expected_alert_title}*' 471 | } 472 | }, 473 | { 474 | 'type': 'section', 475 | 'text': { 476 | 'type': 'mrkdwn', 477 | 'text': expected_description 478 | } 479 | }, 480 | { 481 | 'type': 'section', 482 | 'text': { 483 | 'type': 'mrkdwn', 484 | 'text': f'```{expected_log_line}```' 485 | } 486 | }, 487 | { 488 | 'type': 'divider' 489 | }, 490 | { 491 | 'type': 'section', 492 | 'fields': [ 493 | { 494 | 'type': 'mrkdwn', 495 | 'text': f'*Source:* {expected_provider} {expected_service_name}' 496 | }, 497 | { 498 | 'type': 'mrkdwn', 499 | 'text': f'*Account id:* {expected_account_id}' 500 | }, 501 | { 502 | 'type': 'mrkdwn', 503 | 'text': f'*Timestamp*: {expected_timestamp_zulu}' 504 | }, 505 | { 506 | 'type': 'mrkdwn', 507 | 'text': '*Region:* us-east-1' 508 | } 509 | ] 510 | }, 511 | { 512 | 'type': 'divider' 513 | }, 514 | { 515 | 'type': 'context', 516 | 'elements': [ 517 | { 518 | 'type': 'mrkdwn', 519 | 'text': f'*Event id*: {expected_event_id}' 520 | } 521 | ] 522 | } 523 | ] 524 | } 525 | 526 | expected_stdf_message = { 527 | 'payload': { 528 | 'title': expected_alert_title, 529 | 'description': expected_description, 530 | 'raw_data': expected_log_line 531 | }, 532 | 'meta': { 533 | 'timestamp': expected_timestamp_milliseconds, 534 | 'source': { 535 | 'provider': expected_provider, 536 | 'account_id': expected_account_id, 537 | 'region': expected_region, 538 | 'service': expected_service_name, 539 | 'event_id': expected_event_id, 540 | 'app_name': expected_app_name 541 | }, 542 | }, 543 | 'stdf_version': 2, 544 | } 545 | 546 | expected_stdf_message_raw = json.dumps(expected_stdf_message) 547 | 548 | # Act 549 | actual_formatted_message = lambda_function.format_stdf(expected_stdf_message_raw) 550 | 551 | # Assert 552 | self.assertEqual(actual_formatted_message, expected_formatted_message) 553 | mock_format_urls.assert_not_called() 554 | mock_get_zulu_timestamp.assert_called_once_with(expected_timestamp_milliseconds) 555 | 556 | @unittest.mock.patch('lambda_slack.lambda_function.get_zulu_timestamp') 557 | @unittest.mock.patch('lambda_slack.lambda_function.format_urls') 558 | def test_format_stdf_no_urls_no_event_id_provided(self, mock_format_urls, mock_get_zulu_timestamp): 559 | # Arrange 560 | expected_provider = 'AWS' 561 | expected_service_name = 'CloudWatch' 562 | expected_region = 'us-east-1' 563 | expected_account_id = '123456789012' 564 | expected_timestamp_zulu = '2020-02-20T02:20:20Z' 565 | mock_get_zulu_timestamp.return_value = expected_timestamp_zulu 566 | expected_timestamp_milliseconds = 1582154420 * 1000 567 | expected_log_line = '2020-03-26 08:36:27.000000000 0900 http_logs: {\'host\':\'10.112.250.161\',\'user\':null,\'method\':\'POST\',\'path\':\'/saml/SSO\',\'code\':302,\'size\':null,\'referer\':null,\'agent\':null}' 568 | 569 | expected_description = 'Something happened. A lot of things actually so this text is really long. This is a very detailed description innit.' 570 | expected_app_name = 'Jamf' 571 | expected_alert_title = 'BambooHR sync failed' 572 | 573 | expected_formatted_message = { 574 | 'blocks': [ 575 | { 576 | 'type': 'section', 577 | 'text': { 578 | 'type': 'mrkdwn', 579 | 'text': f'*{expected_app_name}*: *{expected_alert_title}*' 580 | } 581 | }, 582 | { 583 | 'type': 'section', 584 | 'text': { 585 | 'type': 'mrkdwn', 586 | 'text': expected_description 587 | } 588 | }, 589 | { 590 | 'type': 'section', 591 | 'text': { 592 | 'type': 'mrkdwn', 593 | 'text': f'```{expected_log_line}```' 594 | } 595 | }, 596 | { 597 | 'type': 'divider' 598 | }, 599 | { 600 | 'type': 'section', 601 | 'fields': [ 602 | { 603 | 'type': 'mrkdwn', 604 | 'text': f'*Source:* {expected_provider} {expected_service_name}' 605 | }, 606 | { 607 | 'type': 'mrkdwn', 608 | 'text': f'*Account id:* {expected_account_id}' 609 | }, 610 | { 611 | 'type': 'mrkdwn', 612 | 'text': f'*Timestamp*: {expected_timestamp_zulu}' 613 | }, 614 | { 615 | 'type': 'mrkdwn', 616 | 'text': '*Region:* us-east-1' 617 | } 618 | ] 619 | }, 620 | { 621 | 'type': 'divider' 622 | }, 623 | ] 624 | } 625 | 626 | expected_stdf_message = { 627 | 'payload': { 628 | 'title': expected_alert_title, 629 | 'description': expected_description, 630 | 'raw_data': expected_log_line 631 | }, 632 | 'meta': { 633 | 'timestamp': expected_timestamp_milliseconds, 634 | 'source': { 635 | 'provider': expected_provider, 636 | 'account_id': expected_account_id, 637 | 'region': expected_region, 638 | 'service': expected_service_name, 639 | 'app_name': expected_app_name 640 | }, 641 | }, 642 | 'stdf_version': 2, 643 | } 644 | 645 | expected_stdf_message_raw = json.dumps(expected_stdf_message) 646 | 647 | # Act 648 | actual_formatted_message = lambda_function.format_stdf(expected_stdf_message_raw) 649 | 650 | # Assert 651 | self.assertEqual(actual_formatted_message, expected_formatted_message) 652 | mock_format_urls.assert_not_called() 653 | mock_get_zulu_timestamp.assert_called_once_with(expected_timestamp_milliseconds) 654 | 655 | 656 | class TestFormatFallBack(unittest.TestCase): 657 | def setUp(self): 658 | importlib.reload(lambda_function) 659 | 660 | def test_format_fallback(self): 661 | # Arrange 662 | expected_message = 'raw hardcore log message' 663 | expected_fallback_reason = 'parsing error' 664 | expected_text = f'{expected_fallback_reason}. Failed to format the message. Using fallback mode!\n{expected_message}' 665 | expected_fallback_message = { 666 | 'text': expected_text 667 | } 668 | 669 | # Act 670 | actual_fallback_message = lambda_function.format_fallback(expected_message, expected_fallback_reason) 671 | 672 | # Assert 673 | self.assertEqual(actual_fallback_message, expected_fallback_message) 674 | 675 | 676 | class TestLambdaHandler(unittest.TestCase): 677 | def setUp(self): 678 | importlib.reload(lambda_function) 679 | 680 | @unittest.mock.patch('lambda_slack.lambda_function.error_if_not_exactly_one_record') 681 | @unittest.mock.patch('lambda_slack.lambda_function.get_subject_and_message') 682 | @unittest.mock.patch('lambda_slack.lambda_function.get_webhook') 683 | @unittest.mock.patch('lambda_slack.lambda_function.format_stdf') 684 | @unittest.mock.patch('lambda_slack.lambda_function.format_fallback') 685 | @unittest.mock.patch('lambda_slack.lambda_function.post_to_slack') 686 | def test_handle_incoming_stdf(self, mock_post_to_slack, mock_format_fallback, mock_format_stdf, mock_get_webhook, 687 | mock_get_subject_and_message, mock_error_if_not_exactly_one_record): 688 | # Arrange 689 | expected_message = '{"a": "b"}' 690 | expected_subject = 'stdfMessage' 691 | expected_slack_message = 'slackmessage' 692 | expected_webhook = { 693 | 'host': 'http://a.a', 694 | 'path': '/a/b/c' 695 | } 696 | 697 | expected_context = {} 698 | 699 | expected_event = { 700 | 'Records': [ 701 | { 702 | 'EventVersion': '1.0', 703 | 'EventSubscriptionArn': 'arn:aws:sns:us-east-2:123456789012:sns-lambda:21be56ed-a058-49f5-8c98-aedd2564c486', 704 | 'EventSource': 'aws:sns', 705 | 'Sns': { 706 | 'SignatureVersion': '1', 707 | 'Timestamp': '2019-01-02T12:45:07.000Z', 708 | 'Signature': 'tcc6faL2yUC6dgZdmrwh1Y4cGa/ebXEkAi6RibDsvpi+tE/1+82j...65r==', 709 | 'SigningCertUrl': 'https://sns.us-east-2.amazonaws.com/SimpleNotificationService-ac565b8b1a6c5d002d285f9598aa1d9b.pem', 710 | 'MessageId': '95df01b4-ee98-5cb9-9903-4c221d41eb5e', 711 | 'Message': expected_message, 712 | 'MessageAttributes': { 713 | 'Test': { 714 | 'Type': 'String', 715 | 'Value': 'TestString' 716 | }, 717 | 'TestBinary': { 718 | 'Type': 'Binary', 719 | 'Value': 'TestBinary' 720 | } 721 | }, 722 | 'Type': 'Notification', 723 | 'UnsubscribeUrl': 'https://sns.us-east-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-east-2:123456789012:test-lambda:21be56ed-a058-49f5-8c98-aedd2564c486', 724 | 'TopicArn': 'arn:aws:sns:us-east-2:123456789012:sns-lambda', 725 | 'Subject': expected_subject 726 | } 727 | } 728 | ] 729 | } 730 | 731 | mock_get_subject_and_message.return_value = (expected_subject, expected_message) 732 | mock_get_webhook.return_value = expected_webhook 733 | mock_format_stdf.return_value = expected_slack_message 734 | 735 | # Act 736 | lambda_function.lambda_handler(expected_event, expected_context) 737 | 738 | # Assert 739 | mock_error_if_not_exactly_one_record.assert_called_once_with(expected_event) 740 | mock_get_subject_and_message.assert_called_once_with(expected_event) 741 | mock_get_webhook.assert_called_once() 742 | mock_format_stdf.assert_called_once_with(expected_message) 743 | mock_post_to_slack.assert_called_once_with(expected_webhook, expected_slack_message) 744 | mock_format_fallback.assert_not_called() 745 | 746 | @unittest.mock.patch('lambda_slack.lambda_function.error_if_not_exactly_one_record') 747 | @unittest.mock.patch('lambda_slack.lambda_function.get_subject_and_message') 748 | @unittest.mock.patch('lambda_slack.lambda_function.get_webhook') 749 | @unittest.mock.patch('lambda_slack.lambda_function.format_stdf') 750 | @unittest.mock.patch('lambda_slack.lambda_function.format_fallback') 751 | @unittest.mock.patch('lambda_slack.lambda_function.post_to_slack') 752 | def test_handle_incoming_stdf_extended(self, mock_post_to_slack, mock_format_fallback, mock_format_stdf, mock_get_webhook, 753 | mock_get_subject_and_message, mock_error_if_not_exactly_one_record): 754 | # Arrange 755 | expected_message = '{"a": "b"}' 756 | expected_subject = 'stdf: some alert happened!' 757 | expected_slack_message = 'slackmessage' 758 | expected_webhook = { 759 | 'host': 'http://a.a', 760 | 'path': '/a/b/c' 761 | } 762 | 763 | expected_context = {} 764 | 765 | expected_event = { 766 | 'Records': [ 767 | { 768 | 'EventVersion': '1.0', 769 | 'EventSubscriptionArn': 'arn:aws:sns:us-east-2:123456789012:sns-lambda:21be56ed-a058-49f5-8c98-aedd2564c486', 770 | 'EventSource': 'aws:sns', 771 | 'Sns': { 772 | 'SignatureVersion': '1', 773 | 'Timestamp': '2019-01-02T12:45:07.000Z', 774 | 'Signature': 'tcc6faL2yUC6dgZdmrwh1Y4cGa/ebXEkAi6RibDsvpi+tE/1+82j...65r==', 775 | 'SigningCertUrl': 'https://sns.us-east-2.amazonaws.com/SimpleNotificationService-ac565b8b1a6c5d002d285f9598aa1d9b.pem', 776 | 'MessageId': '95df01b4-ee98-5cb9-9903-4c221d41eb5e', 777 | 'Message': expected_message, 778 | 'MessageAttributes': { 779 | 'Test': { 780 | 'Type': 'String', 781 | 'Value': 'TestString' 782 | }, 783 | 'TestBinary': { 784 | 'Type': 'Binary', 785 | 'Value': 'TestBinary' 786 | } 787 | }, 788 | 'Type': 'Notification', 789 | 'UnsubscribeUrl': 'https://sns.us-east-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-east-2:123456789012:test-lambda:21be56ed-a058-49f5-8c98-aedd2564c486', 790 | 'TopicArn': 'arn:aws:sns:us-east-2:123456789012:sns-lambda', 791 | 'Subject': expected_subject 792 | } 793 | } 794 | ] 795 | } 796 | 797 | mock_get_subject_and_message.return_value = (expected_subject, expected_message) 798 | mock_get_webhook.return_value = expected_webhook 799 | mock_format_stdf.return_value = expected_slack_message 800 | 801 | # Act 802 | lambda_function.lambda_handler(expected_event, expected_context) 803 | 804 | # Assert 805 | mock_error_if_not_exactly_one_record.assert_called_once_with(expected_event) 806 | mock_get_subject_and_message.assert_called_once_with(expected_event) 807 | mock_get_webhook.assert_called_once() 808 | mock_format_stdf.assert_called_once_with(expected_message) 809 | mock_post_to_slack.assert_called_once_with(expected_webhook, expected_slack_message) 810 | mock_format_fallback.assert_not_called() 811 | 812 | 813 | @unittest.mock.patch('lambda_slack.lambda_function.error_if_not_exactly_one_record') 814 | @unittest.mock.patch('lambda_slack.lambda_function.get_subject_and_message') 815 | @unittest.mock.patch('lambda_slack.lambda_function.get_webhook') 816 | @unittest.mock.patch('lambda_slack.lambda_function.format_stdf') 817 | @unittest.mock.patch('lambda_slack.lambda_function.format_fallback') 818 | @unittest.mock.patch('lambda_slack.lambda_function.post_to_slack') 819 | def test_handle_incoming_unknown(self, mock_post_to_slack, mock_format_fallback, mock_format_stdf, mock_get_webhook, 820 | mock_get_subject_and_message, mock_error_if_not_exactly_one_record): 821 | # Arrange 822 | expected_message = '{"a": "b"}' 823 | expected_subject = 'unknownMessage' 824 | expected_slack_message = 'slackmessage' 825 | expected_slack_message_fallback = 'fallbackmessage' 826 | expected_webhook = { 827 | 'host': 'http://a.a', 828 | 'path': '/a/b/c' 829 | } 830 | 831 | expected_context = {} 832 | 833 | expected_event = { 834 | 'Records': [ 835 | { 836 | 'EventVersion': '1.0', 837 | 'EventSubscriptionArn': 'arn:aws:sns:us-east-2:123456789012:sns-lambda:21be56ed-a058-49f5-8c98-aedd2564c486', 838 | 'EventSource': 'aws:sns', 839 | 'Sns': { 840 | 'SignatureVersion': '1', 841 | 'Timestamp': '2019-01-02T12:45:07.000Z', 842 | 'Signature': 'tcc6faL2yUC6dgZdmrwh1Y4cGa/ebXEkAi6RibDsvpi+tE/1+82j...65r==', 843 | 'SigningCertUrl': 'https://sns.us-east-2.amazonaws.com/SimpleNotificationService-ac565b8b1a6c5d002d285f9598aa1d9b.pem', 844 | 'MessageId': '95df01b4-ee98-5cb9-9903-4c221d41eb5e', 845 | 'Message': expected_message, 846 | 'MessageAttributes': { 847 | 'Test': { 848 | 'Type': 'String', 849 | 'Value': 'TestString' 850 | }, 851 | 'TestBinary': { 852 | 'Type': 'Binary', 853 | 'Value': 'TestBinary' 854 | } 855 | }, 856 | 'Type': 'Notification', 857 | 'UnsubscribeUrl': 'https://sns.us-east-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-east-2:123456789012:test-lambda:21be56ed-a058-49f5-8c98-aedd2564c486', 858 | 'TopicArn': 'arn:aws:sns:us-east-2:123456789012:sns-lambda', 859 | 'Subject': expected_subject 860 | } 861 | } 862 | ] 863 | } 864 | 865 | mock_get_subject_and_message.return_value = (expected_subject, expected_message) 866 | mock_get_webhook.return_value = expected_webhook 867 | mock_format_stdf.return_value = expected_slack_message 868 | mock_format_fallback.return_value = expected_slack_message_fallback 869 | 870 | # Act 871 | lambda_function.lambda_handler(expected_event, expected_context) 872 | 873 | # Assert 874 | mock_error_if_not_exactly_one_record.assert_called_once_with(expected_event) 875 | mock_get_subject_and_message.assert_called_once_with(expected_event) 876 | mock_get_webhook.assert_called_once() 877 | mock_format_fallback.assert_called_once_with(expected_message, fallback_reason=expected_subject) 878 | mock_post_to_slack.assert_called_once_with(expected_webhook, expected_slack_message_fallback) 879 | mock_format_stdf.assert_not_called() 880 | 881 | @unittest.mock.patch('lambda_slack.lambda_function.error_if_not_exactly_one_record') 882 | @unittest.mock.patch('lambda_slack.lambda_function.get_subject_and_message') 883 | @unittest.mock.patch('lambda_slack.lambda_function.get_webhook') 884 | @unittest.mock.patch('lambda_slack.lambda_function.format_stdf') 885 | @unittest.mock.patch('lambda_slack.lambda_function.format_fallback') 886 | @unittest.mock.patch('lambda_slack.lambda_function.post_to_slack') 887 | def test_handle_incoming_postfailed(self, mock_post_to_slack, mock_format_fallback, mock_format_stdf, 888 | mock_get_webhook, mock_get_subject_and_message, 889 | mock_error_if_not_exactly_one_record): 890 | # Arrange 891 | expected_message = '{"a": "b"}' 892 | expected_subject = 'stdfMessage' 893 | expected_slack_message = 'slackmessage' 894 | expected_slack_message_fallback = 'fallbackmessage' 895 | expected_error = '403' 896 | expected_webhook = { 897 | 'host': 'http://a.a', 898 | 'path': '/a/b/c' 899 | } 900 | 901 | expected_context = {} 902 | 903 | expected_event = { 904 | 'Records': [ 905 | { 906 | 'EventVersion': '1.0', 907 | 'EventSubscriptionArn': 'arn:aws:sns:us-east-2:123456789012:sns-lambda:21be56ed-a058-49f5-8c98-aedd2564c486', 908 | 'EventSource': 'aws:sns', 909 | 'Sns': { 910 | 'SignatureVersion': '1', 911 | 'Timestamp': '2019-01-02T12:45:07.000Z', 912 | 'Signature': 'tcc6faL2yUC6dgZdmrwh1Y4cGa/ebXEkAi6RibDsvpi+tE/1+82j...65r==', 913 | 'SigningCertUrl': 'https://sns.us-east-2.amazonaws.com/SimpleNotificationService-ac565b8b1a6c5d002d285f9598aa1d9b.pem', 914 | 'MessageId': '95df01b4-ee98-5cb9-9903-4c221d41eb5e', 915 | 'Message': expected_message, 916 | 'MessageAttributes': { 917 | 'Test': { 918 | 'Type': 'String', 919 | 'Value': 'TestString' 920 | }, 921 | 'TestBinary': { 922 | 'Type': 'Binary', 923 | 'Value': 'TestBinary' 924 | } 925 | }, 926 | 'Type': 'Notification', 927 | 'UnsubscribeUrl': 'https://sns.us-east-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-east-2:123456789012:test-lambda:21be56ed-a058-49f5-8c98-aedd2564c486', 928 | 'TopicArn': 'arn:aws:sns:us-east-2:123456789012:sns-lambda', 929 | 'Subject': expected_subject 930 | } 931 | } 932 | ] 933 | } 934 | 935 | expected_post_to_slack_calls = [ 936 | unittest.mock.call(expected_webhook, expected_slack_message), 937 | unittest.mock.call(expected_webhook, expected_slack_message_fallback) 938 | ] 939 | 940 | expected_post_to_slack_side_effect = [ 941 | ValueError(expected_error), 942 | None 943 | ] 944 | 945 | mock_post_to_slack.side_effect = expected_post_to_slack_side_effect 946 | mock_get_subject_and_message.return_value = (expected_subject, expected_message) 947 | mock_get_webhook.return_value = expected_webhook 948 | mock_format_stdf.return_value = expected_slack_message 949 | mock_format_fallback.return_value = expected_slack_message_fallback 950 | 951 | # Act 952 | lambda_function.lambda_handler(expected_event, expected_context) 953 | 954 | # Assert 955 | mock_error_if_not_exactly_one_record.assert_called_once_with(expected_event) 956 | mock_get_subject_and_message.assert_called_once_with(expected_event) 957 | mock_get_webhook.assert_called_once() 958 | mock_format_stdf.assert_called_once_with(expected_message) 959 | mock_format_fallback.assert_called_once_with(expected_message, fallback_reason=expected_error) 960 | mock_post_to_slack.assert_has_calls(expected_post_to_slack_calls) 961 | --------------------------------------------------------------------------------