From d2675eca91f3ca4bc8b7a18912ae84b36b7922f1 Mon Sep 17 00:00:00 2001 From: Bryant Biggs Date: Fri, 10 Dec 2021 15:57:11 -0500 Subject: [PATCH] feat: add lint and unit test workflow checks for pull requests (#152) --- .github/workflows/unit-test.yml | 12 + .gitignore | 55 +- README.md | 23 +- examples/notify-slack-simple/README.md | 5 +- examples/notify-slack-simple/main.tf | 42 +- examples/notify-slack-simple/versions.tf | 4 + functions/.flake8 | 10 + functions/.pyproject.toml | 58 +++ functions/Pipfile | 36 ++ functions/README.md | 122 +++++ functions/events/cloudwatch_alarm.json | 10 + functions/events/guardduty_finding_high.json | 16 + functions/events/guardduty_finding_low.json | 16 + .../events/guardduty_finding_medium.json | 16 + functions/integration_test.py | 93 ++++ functions/messages/cloudwatch_alarm.json | 20 + functions/messages/dms_notification.json | 20 + functions/messages/glue_notification.json | 20 + functions/messages/guardduty_finding.json | 20 + functions/messages/text_message.json | 20 + functions/notify_slack.py | 485 ++++++++++++------ functions/notify_slack_test.py | 337 ++++++------ functions/pytest.ini.sample | 7 - functions/requirements.txt | 4 - functions/snapshots/__init__.py | 0 functions/snapshots/snap_notify_slack_test.py | 383 ++++++++++++++ 26 files changed, 1442 insertions(+), 392 deletions(-) create mode 100644 functions/.flake8 create mode 100644 functions/.pyproject.toml create mode 100644 functions/Pipfile create mode 100644 functions/README.md create mode 100644 functions/events/cloudwatch_alarm.json create mode 100644 functions/events/guardduty_finding_high.json create mode 100644 functions/events/guardduty_finding_low.json create mode 100644 functions/events/guardduty_finding_medium.json create mode 100644 functions/integration_test.py create mode 100644 functions/messages/cloudwatch_alarm.json create mode 100644 functions/messages/dms_notification.json create mode 100644 functions/messages/glue_notification.json create mode 100644 functions/messages/guardduty_finding.json create mode 100644 functions/messages/text_message.json delete mode 100644 functions/pytest.ini.sample delete mode 100644 functions/requirements.txt create mode 100644 functions/snapshots/__init__.py create mode 100644 functions/snapshots/snap_notify_slack_test.py diff --git a/.github/workflows/unit-test.yml b/.github/workflows/unit-test.yml index c1188980..450b8a00 100644 --- a/.github/workflows/unit-test.yml +++ b/.github/workflows/unit-test.yml @@ -31,3 +31,15 @@ jobs: run: | python -m pip install --upgrade pip python -m pip install pipenv + + - name: Install local deps + run: pipenv install --dev + + - name: Lint check + run: pipenv run lint:ci + + - name: Type check + run: pipenv run typecheck + + - name: Unit tests + run: pipenv run test diff --git a/.gitignore b/.gitignore index 927c2793..2cb4b5c3 100644 --- a/.gitignore +++ b/.gitignore @@ -28,8 +28,59 @@ override.tf.json .terraformrc terraform.rc +# Locals +.swp +.idea +.idea* +.vscode/* +*.DS_Store +*.zip +.env +.envrc + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +.Python +env/ +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +*.egg-info/ +.installed.cfg +*.egg + +# Unit test / coverage reports +.pytest* +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.coverage +.hypothesis/ +.mypy_cache/ + +# Lockfile +Pipfile.lock + # Lambda directories builds/ -__pycache__/ functions/pytest.ini -*.zip + +# Integration testing file +.int.env diff --git a/README.md b/README.md index 16018521..87773996 100644 --- a/README.md +++ b/README.md @@ -12,15 +12,11 @@ Doing serverless with Terraform? Check out [serverless.tf framework](https://ser - Create new SNS topic or use existing one - Support plaintext and encrypted version of Slack webhook URL - Most of Slack message options are customizable -- Support different types of SNS messages: +- Various event types are supported, even generic messages: - AWS CloudWatch Alarms - AWS CloudWatch LogMetrics Alarms - AWS GuardDuty Findings -- Local pytest driven testing of the lambda to a Slack sandbox channel -## Feature Roadmap - -- More SNS message types: [Send pull-request to add support of other message types](https://github.com/terraform-aws-modules/terraform-aws-notify-slack/pulls) ## Usage @@ -37,10 +33,6 @@ module "notify_slack" { } ``` -## Upgrade from 2.0 to 3.0 - -Version 3 uses [Terraform AWS Lambda module](https://github.com/terraform-aws-modules/terraform-aws-lambda) to handle most of heavy-lifting related to Lambda packaging, roles, and permissions, while maintaining the same interface for the user of this module after many of resources will be recreated. - ## Using with Terraform Cloud Agents [Terraform Cloud Agents](https://www.terraform.io/docs/cloud/workspaces/agent.html) are a paid feature, available as part of the Terraform Cloud for Business upgrade package. @@ -62,18 +54,9 @@ If you want to subscribe the AWS Lambda Function created by this module to an ex - [notify-slack-simple](https://github.com/terraform-aws-modules/terraform-aws-notify-slack/tree/master/examples/notify-slack-simple) - Creates SNS topic which sends messages to Slack channel. - [cloudwatch-alerts-to-slack](https://github.com/terraform-aws-modules/terraform-aws-notify-slack/tree/master/examples/cloudwatch-alerts-to-slack) - End to end example which shows how to send AWS Cloudwatch alerts to Slack channel and use KMS to encrypt webhook URL. -## Testing with pytest - -To run the tests: - -1. Set up a dedicated slack channel as a test sandbox with it's own webhook. See [Slack Incoming Webhooks docs](https://api.slack.com/messaging/webhooks) for details. -2. Make a copy of the sample pytest configuration and edit as needed. - - cp functions/pytest.ini.sample functions/pytest.ini - -3. Run the tests: +## Local Development and Testing - pytest functions/notify_slack_test.py +See the [functions](https://github.com/terraform-aws-modules/terraform-aws-notify-slack/tree/master/functions) for further details. ## Requirements diff --git a/examples/notify-slack-simple/README.md b/examples/notify-slack-simple/README.md index 62f0deb8..620a27d1 100644 --- a/examples/notify-slack-simple/README.md +++ b/examples/notify-slack-simple/README.md @@ -25,12 +25,14 @@ Note that this example may create resources which can cost money (AWS Elastic IP |------|---------| | [terraform](#requirement\_terraform) | >= 0.13.1 | | [aws](#requirement\_aws) | >= 3.61 | +| [local](#requirement\_local) | >= 2.0 | ## Providers | Name | Version | |------|---------| | [aws](#provider\_aws) | >= 3.61 | +| [local](#provider\_local) | >= 2.0 | ## Modules @@ -42,7 +44,8 @@ Note that this example may create resources which can cost money (AWS Elastic IP | Name | Type | |------|------| -| [aws_sns_topic.my_sns](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sns_topic) | resource | +| [aws_sns_topic.example](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/sns_topic) | resource | +| [local_file.integration_testing](https://registry.terraform.io/providers/hashicorp/local/latest/docs/resources/file) | resource | ## Inputs diff --git a/examples/notify-slack-simple/main.tf b/examples/notify-slack-simple/main.tf index f3e8e9bc..2a72be1d 100644 --- a/examples/notify-slack-simple/main.tf +++ b/examples/notify-slack-simple/main.tf @@ -1,24 +1,52 @@ provider "aws" { + region = local.region +} + +locals { + name = "ex-${replace(basename(path.cwd), "_", "-")}" region = "eu-west-1" + tags = { + Owner = "user" + Environment = "dev" + } } -resource "aws_sns_topic" "my_sns" { - name = "my-sns" +################################################################################ +# Supporting Resources +################################################################################ + +resource "aws_sns_topic" "example" { + name = local.name + tags = local.tags } +################################################################################ +# Slack Notify Module +################################################################################ + module "notify_slack" { source = "../../" - sns_topic_name = aws_sns_topic.my_sns.name + sns_topic_name = aws_sns_topic.example.name create_sns_topic = false slack_webhook_url = "https://hooks.slack.com/services/AAA/BBB/CCC" slack_channel = "aws-notification" slack_username = "reporter" - tags = { - Name = "notify-slack-simple" - } + tags = local.tags +} + +################################################################################ +# Integration Testing Support +# This populates a file that is gitignored to aid in executing the integration tests locally +################################################################################ - depends_on = [aws_sns_topic.my_sns] +resource "local_file" "integration_testing" { + filename = "${path.module}/../../functions/.int.env" + content = <<-EOT + REGION=${local.region} + LAMBDA_FUNCTION_NAME=${module.notify_slack.notify_slack_lambda_function_name} + SNS_TOPIC_ARN=${aws_sns_topic.example.arn} + EOT } diff --git a/examples/notify-slack-simple/versions.tf b/examples/notify-slack-simple/versions.tf index 4440a44d..d957c7b4 100644 --- a/examples/notify-slack-simple/versions.tf +++ b/examples/notify-slack-simple/versions.tf @@ -6,5 +6,9 @@ terraform { source = "hashicorp/aws" version = ">= 3.61" } + local = { + source = "hashicorp/local" + version = ">= 2.0" + } } } diff --git a/functions/.flake8 b/functions/.flake8 new file mode 100644 index 00000000..76618dc5 --- /dev/null +++ b/functions/.flake8 @@ -0,0 +1,10 @@ +[flake8] +max-complexity = 10 +max-line-length = 120 +exclude = + .pytest_cache + __pycache__/ + *tests/ + events/ + messages/ + snapshots/ diff --git a/functions/.pyproject.toml b/functions/.pyproject.toml new file mode 100644 index 00000000..c0374cea --- /dev/null +++ b/functions/.pyproject.toml @@ -0,0 +1,58 @@ +[tool.black] +line-length = 120 +target-version = ['py38'] +include = '\.pyi?$' +verbose = true +exclude = ''' +/( + | \.git + | \.mypy_cache + | dist + | \.pants\.d + | virtualenvs + | \.venv + | _build + | build + | dist + | snapshots +)/ +''' + +[tool.isort] +line_length = 120 +skip = '.terraform' +sections = 'FUTURE,STDLIB,THIRDPARTY,FIRSTPARTY,LOCALFOLDER' +known_third_party = 'aws_lambda_powertools,boto3,botocore,pytest,snapshottest' +known_first_party = 'events,notify_slack' +indent = ' ' + +[tool.mypy] +namespace_packages = true +explicit_package_bases = true + +no_implicit_optional = true +implicit_reexport = false +strict_equality = true + +warn_unused_configs = true +warn_unused_ignores = true +warn_return_any = true +warn_redundant_casts = true +warn_unreachable = true + +pretty = true +show_column_numbers = true +show_error_context = true +show_error_codes = true +show_traceback = true + +[tool.coverage.run] +branch = true +omit = ["*_test.py", "tests/*", "events/*", "messages/*", "snapshots/*", "venv/*", ".mypy_cache/*", ".pytest_cache/*"] + +[tool.coverage.report] +show_missing = true +skip_covered = true +skip_empty = true +sort = "-Miss" +fail_under = 75 diff --git a/functions/Pipfile b/functions/Pipfile new file mode 100644 index 00000000..af8ac944 --- /dev/null +++ b/functions/Pipfile @@ -0,0 +1,36 @@ +[[source]] +url = "https://pypi.org/simple" +verify_ssl = true +name = "pypi" + +[packages] + +[dev-packages] +boto3 = "~=1.20" +botocore = "~=1.23" +black = "*" +flake8 = "*" +isort = "*" +mypy = "*" +pytest = "*" +pytest-cov = "*" +radon = "*" +snapshottest = "~=0.6" + +[requires] +python_version = "3.8" + +[scripts] +test = "python3 -m pytest --cov --cov-report=term" +'test:updatesnapshots' = "python3 -m pytest --snapshot-update" +cover = "python3 -m coverage html" +complexity = "python3 -m radon cc notify_slack.py -a" +halstead = "python3 -m radon hal notify_slack.py" +typecheck = "python3 -m mypy . --ignore-missing-imports" +lint = "python3 -m flake8 . --count --statistics --benchmark --exit-zero --config=.flake8" +'lint:ci' = "python3 -m flake8 . --config=.flake8" +imports = "python3 -m isort . --profile black" +format = "python3 -m black ." + +[pipenv] +allow_prereleases = true diff --git a/functions/README.md b/functions/README.md new file mode 100644 index 00000000..996f2023 --- /dev/null +++ b/functions/README.md @@ -0,0 +1,122 @@ +# Slack Notify Lambda Functions + +## Conventions + +The following tools and conventions are used within this project: + +- [pipenv](https://github.com/pypa/pipenv) for managing Python dependencies and development virtualenv +- [flake8](https://github.com/PyCQA/flake8) & [radon](https://github.com/rubik/radon) for linting and static code analysis +- [isort](https://github.com/timothycrosley/isort) for import statement formatting +- [black](https://github.com/ambv/black) for code formatting +- [mypy](https://github.com/python/mypy) for static type checking +- [pytest](https://github.com/pytest-dev/pytest) and [snapshottest](https://github.com/syrusakbary/snapshottest) for unit testing and snapshot testing + +## Getting Started + +The following instructions will help you get setup for local development and testing purposes. + +### Prerequisites + +#### [Pipenv](https://github.com/pypa/pipenv) + +Pipenv is used to help manage the python dependencies and local virtualenv for local testing and development. To install `pipenv` please refer to the project [installation documentation](https://github.com/pypa/pipenv#installation). + +Install the projects Python dependencies (with development dependencies) locally by running the following command. + +```bash + $ pipenv install --dev +``` + +If you add/change/modify any of the Pipfile dependencies, you can update your local virtualenv using: + +```bash + $ pipenv update +``` + +### Testing + +#### Sample Payloads + +In the `functions/` directory there are two folders that contain sample message payloads used for testing and validation: + +1. `functions/events/` contains raw events as provided by AWS. You can see a more in-depth list of example events in the (AWS documentation](https://docs.aws.amazon.com/AmazonCloudWatch/latest/events/EventTypes.html) +2. `functions/messages/` contains SNS message payloads in the form that they are delivered to the Slack notify lambda function. The `Message` attribute field is where the payload is stored that will be parsed and sent to Slack; this can be events like those described above in #1, or any string/stringified-JSON + +#### Unit Tests + +There are a number of pipenv scripts that are provided to aid in testing and ensuring the codebase is formatted properly. + +- `pipenv run test`: execute unit tests defined using pytest and show test coverage +- `pipenv run lint`: show linting errors and static analysis of codebase +- `pipenv run format`: auto-format codebase according to configurations provided +- `pipenv run imports`: auto-format import statements according to configurations provided +- `pipenv run typecheck`: show typecheck analysis of codebase + +See the `[scripts]` section of the `Pipfile` for the complete list of script commands. + +#### Snapshot Testing + +Snapshot testing is used to compare a set of given inputs to generated output snapshots to aid in unit testing. The tests are run in conjunction with the standard unit tests and the output will be shown in the cumulative output from `pipenv run test`. In theory, however, the snapshots themselves should not change unless: + +1. The expected output of the message payload has changed +2. Event/message payloads have been added to or removed from the project + +When a change is required to update the snapshots, please do the following: + +1. Update the snapshots by running: + +```bash + $ pipenv run test:updatesnapshots + $ pipenv run format # this is necessary since the generated code follows its own style +``` + +2. Provide a clear reasoning within your pull request as to why the snapshots have changed + +#### Integration Tests + +Integration tests require setting up a live Slack webhook + +To run the unit tests: + +1. Set up a dedicated slack channel as a test sandbox with it's own webhook. See [Slack Incoming Webhooks docs](https://api.slack.com/messaging/webhooks) for details. +2. From within the `examples/notify-slack-simple/` directory, update the `slack_*` variables to use your values: + +```hcl + slack_webhook_url = "https://hooks.slack.com/services/AAA/BBB/CCC" + slack_channel = "aws-notification" + slack_username = "reporter" +``` + +3. Deploy the resources in the `examples/notify-slack-simple/` project using Terraform + +```bash + $ terraform init && terraform apply -y +``` + +4. From within the `functions/` directory, execute the integration tests locally: + +```bash + $ pipenv run python integration_test.py +``` + +Within the Slack channel that is associated to the webhook URL provided, you should see all of the messages arriving. You can compared the messages to the payloads in the `functions/events/` and `functions/messages` directories; there should be one Slack message per event payload/file. + +5. Do not forget to clean up your provisioned resources by returning to the `example/notify-slack-simple/` directory and destroying using Terraform: + +```bash + $ terraform destroy -y +``` + +## Supporting Additional Events + +To add new events with custom message formatting, the general workflow will consist of (ignoring git actions for brevity): + +1. Add a new example event paylod to the `functions/events/` directory; please name the file, using snake casing, in the form `_.json` such as `guardduty_finding.json` or `cloudwatch_alarm.json` +2. In the `functions/notify_slack.py` file, add the new formatting function, following a similar naming pattern like in step #1 where the function name is `format__()` such as `format_guardduty_finding()` or `format_cloudwatch_alarm()` +3. (Optional) Ff there are different "severity" type levels that are to be mapped to Slack message color bars, create an enum that maps the possible serverity values to the appropriate colors. See the `CloudWatchAlarmState` and `GuardDutyFindingSeverity` for examples. The enum name should follow pascal case, Python standard, in the form of `` +4. Update the snapshots to include your new event payload and expected output. Note - the other snapshots should not be affected by your change, the snapshot diff should only show your new event: + +```bash + $ pipenv run test:updatesnapshots + $ pipenv run format # this is necessary since the generated code follows its own style +``` diff --git a/functions/events/cloudwatch_alarm.json b/functions/events/cloudwatch_alarm.json new file mode 100644 index 00000000..f3f9eb11 --- /dev/null +++ b/functions/events/cloudwatch_alarm.json @@ -0,0 +1,10 @@ +{ + "AlarmName": "Example", + "AlarmDescription": "Example alarm description.", + "AWSAccountId": "000000000000", + "NewStateValue": "ALARM", + "NewStateReason": "Threshold Crossed", + "StateChangeTime": "2017-01-12T16:30:42.236+0000", + "Region": "EU - Ireland", + "OldStateValue": "OK" +} diff --git a/functions/events/guardduty_finding_high.json b/functions/events/guardduty_finding_high.json new file mode 100644 index 00000000..df3fad9d --- /dev/null +++ b/functions/events/guardduty_finding_high.json @@ -0,0 +1,16 @@ +{ + "detail-type": "GuardDuty Finding", + "region": "us-east-1", + "detail": { + "id": "sample-id-2", + "title": "SAMPLE Unprotected port on EC2 instance i-123123123 is being probed", + "severity": 9, + "description": "EC2 instance has an unprotected port which is being probed by a known malicious host.", + "type": "Recon:EC2 PortProbeUnprotectedPort", + "service": { + "eventFirstSeen": "2020-01-02T01:02:03Z", + "eventLastSeen": "2020-01-03T01:02:03Z", + "count": 1234 + } + } +} diff --git a/functions/events/guardduty_finding_low.json b/functions/events/guardduty_finding_low.json new file mode 100644 index 00000000..c19c8f40 --- /dev/null +++ b/functions/events/guardduty_finding_low.json @@ -0,0 +1,16 @@ +{ + "detail-type": "GuardDuty Finding", + "region": "us-east-1", + "detail": { + "id": "sample-id-2", + "title": "SAMPLE Unprotected port on EC2 instance i-123123123 is being probed", + "severity": 2, + "description": "EC2 instance has an unprotected port which is being probed by a known malicious host.", + "type": "Recon:EC2 PortProbeUnprotectedPort", + "service": { + "eventFirstSeen": "2020-01-02T01:02:03Z", + "eventLastSeen": "2020-01-03T01:02:03Z", + "count": 1234 + } + } +} diff --git a/functions/events/guardduty_finding_medium.json b/functions/events/guardduty_finding_medium.json new file mode 100644 index 00000000..0c163446 --- /dev/null +++ b/functions/events/guardduty_finding_medium.json @@ -0,0 +1,16 @@ +{ + "detail-type": "GuardDuty Finding", + "region": "us-east-1", + "detail": { + "id": "sample-id-2", + "title": "SAMPLE Unprotected port on EC2 instance i-123123123 is being probed", + "severity": 5, + "description": "EC2 instance has an unprotected port which is being probed by a known malicious host.", + "type": "Recon:EC2 PortProbeUnprotectedPort", + "service": { + "eventFirstSeen": "2020-01-02T01:02:03Z", + "eventLastSeen": "2020-01-03T01:02:03Z", + "count": 1234 + } + } +} diff --git a/functions/integration_test.py b/functions/integration_test.py new file mode 100644 index 00000000..dd3b71be --- /dev/null +++ b/functions/integration_test.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +""" + Integration Test + ---------------- + + Executes tests against live Slack webhook + +""" + +import os +from pprint import pprint +from typing import List + +import boto3 +import pytest + + +@pytest.mark.skip(reason="Execute with`pytest run python integration_test.py`") +def _get_files(directory: str) -> List[str]: + """ + Helper function to get list of files under `directory` + + :params directory: directory to pull list of files from + :returns: list of files names under directory specified + """ + return [ + os.path.join(directory, f) + for f in os.listdir(directory) + if os.path.isfile(os.path.join(directory, f)) + ] + + +@pytest.mark.skip(reason="Execute with`pytest run python integration_test.py`") +def invoke_lambda_handler(): + """ + Invoke lambda handler with sample SNS messages + + Messages should arrive at the live webhook specified + """ + lambda_client = boto3.client("lambda", region_name=REGION) + + # These are SNS messages that invoke the lambda handler; + # the event payload is in the `message` field + messages = _get_files(directory="./messages") + + for message in messages: + with open(message, "r") as mfile: + msg = mfile.read() + response = lambda_client.invoke( + FunctionName=LAMBDA_FUNCTION_NAME, + InvocationType="Event", + Payload=msg, + ) + pprint(response) + + +@pytest.mark.skip(reason="Execute with`pytest run python integration_test.py`") +def publish_event_to_sns_topic(): + """ + Publish sample events to SNS topic created + + Messages should arrive at the live webhook specified + """ + sns_client = boto3.client("sns", region_name=REGION) + + # These are event payloads that will get published + events = _get_files(directory="./events") + + for event in events: + with open(event, "r") as efile: + msg = efile.read() + response = sns_client.publish( + TopicArn=SNS_TOPIC_ARN, + Message=msg, + Subject=event, + ) + pprint(response) + + +if __name__ == "__main__": + # Sourcing env vars set by `notify-slack-simple` example + with open(".int.env", "r") as envvarfile: + for line in envvarfile.readlines(): + (_var, _val) = line.strip().split("=") + os.environ[_var] = _val + + # Not using .get() so it fails loudly if not set (`KeyError`) + REGION = os.environ["REGION"] + LAMBDA_FUNCTION_NAME = os.environ["LAMBDA_FUNCTION_NAME"] + SNS_TOPIC_ARN = os.environ["SNS_TOPIC_ARN"] + + invoke_lambda_handler() + publish_event_to_sns_topic() diff --git a/functions/messages/cloudwatch_alarm.json b/functions/messages/cloudwatch_alarm.json new file mode 100644 index 00000000..0eba7756 --- /dev/null +++ b/functions/messages/cloudwatch_alarm.json @@ -0,0 +1,20 @@ +{ + "Records": [{ + "EventSource": "aws:sns", + "EventVersion": "1.0", + "EventSubscriptionArn": "arn:aws:sns:us-east-1::ExampleTopic", + "Sns": { + "Type": "Notification", + "MessageId": "f86e3c5b-cd17-1ab8-80e9-c0776d4f1e7a", + "TopicArn": "arn:aws:sns:us-east-1:123456789012:ExampleTopic", + "Subject": "'OK: \"DBMigrationRequired\" in EU (London)", + "Message": "{\"AlarmName\": \"DBMigrationRequired\",\"AlarmDescription\": \"App is reporting \\\"A JPA error occurred(Unable to build EntityManagerFactory)\\\"\",\"AWSAccountId\": \"735598076380\",\"NewStateValue\": \"OK\",\"NewStateReason\": \"Threshold Crossed: 1 datapoint [1.0 (12\/02\/19 15:44:00)] was not less than the threshold (1.0).\",\"StateChangeTime\": \"2019-02-12T15:45:24.006+0000\",\"Region\": \"US (Virginia)\",\"OldStateValue\": \"ALARM\",\"Trigger\": {\"MetricName\": \"DBMigrationRequired\",\"Namespace\": \"LogMetrics\",\"StatisticType\": \"Statistic\",\"Statistic\": \"SUM\",\"Unit\": null,\"Dimensions\": [],\"Period\": 60,\"EvaluationPeriods\": 1,\"ComparisonOperator\": \"LessThanThreshold\",\"Threshold\": 1.0,\"TreatMissingData\": \"- TreatMissingData:NonBreaching\",\"EvaluateLowSampleCountPercentile\": \"\"}}", + "Timestamp": "2019-02-12T15:45:24.091Z", + "SignatureVersion": "1", + "Signature": "EXAMPLE", + "SigningCertUrl": "EXAMPLE", + "UnsubscribeUrl": "EXAMPLE", + "MessageAttributes": {} + } + }] +} diff --git a/functions/messages/dms_notification.json b/functions/messages/dms_notification.json new file mode 100644 index 00000000..68c2a815 --- /dev/null +++ b/functions/messages/dms_notification.json @@ -0,0 +1,20 @@ +{ + "Records": [{ + "EventSource": "aws:sns", + "EventVersion": "1.0", + "EventSubscriptionArn": "arn:aws:sns:eu-west-1::ExampleTopic", + "Sns": { + "Type": "Notification", + "MessageId": "f86e3c5b-cd17-1ab8-80e9-c0776d4f1e7a", + "TopicArn": "arn:aws:sns:eu-west-1:123456789012:ExampleTopic", + "Subject": "DMS Notification Message", + "Message": "{\"Event Source\": \"replication-task\",\"Event Time\": \"2019-02-12 15:45:24.091\",\"Identifier Link\": \"https:\/\/console.aws.amazon.com\/dms\/home?region=us-east-1#tasks:ids=hello-world\",\"SourceId\": \"hello-world\",\"Event ID\": \"http:\/\/docs.aws.amazon.com\/dms\/latest\/userguide\/CHAP_Events.html#DMS-EVENT-0079 \",\"Event Message\": \"Replication task has stopped.\"}", + "Timestamp": "2019-02-12T15:45:24.091Z", + "SignatureVersion": "1", + "Signature": "EXAMPLE", + "SigningCertUrl": "EXAMPLE", + "UnsubscribeUrl": "EXAMPLE", + "MessageAttributes": {} + } + }] +} diff --git a/functions/messages/glue_notification.json b/functions/messages/glue_notification.json new file mode 100644 index 00000000..97fa4648 --- /dev/null +++ b/functions/messages/glue_notification.json @@ -0,0 +1,20 @@ +{ + "Records": [{ + "EventSource": "aws:sns", + "EventVersion": "1.0", + "EventSubscriptionArn": "arn:aws:sns:us-east-2::ExampleTopic", + "Sns": { + "Type": "Notification", + "MessageId": "00337b3f-0982-5cb1-9138-22799c885da9", + "TopicArn": "arn:aws:sns:us-east-2:123456789012:ExampleTopic", + "Subject": "", + "Message": "{\"version\": \"0\",\"id\": \"ad3c3da1-148c-d5da-9a6a-79f1bc9a8a2e\",\"detail-type\": \"Glue Job State Change\",\"source\": \"aws.glue\",\"account\": \"000000000000\",\"time\": \"2021-06-18T12:34:06Z\",\"region\": \"us-east-2\",\"resources\": [],\"detail\": {\"jobName\": \"test_job\",\"severity\": \"ERROR\",\"state\": \"FAILED\",\"jobRunId\": \"jr_ca2144d747b45ad412d3c66a1b6934b6b27aa252be9a21a95c54dfaa224a1925\",\"message\": \"SystemExit: 1\"}}", + "Timestamp": "2021-06-18T12:34:09.509Z", + "SignatureVersion": "1", + "Signature": "EXAMPLE", + "SigningCertUrl": "EXAMPLE", + "UnsubscribeUrl": "EXAMPLE", + "MessageAttributes": {} + } + }] +} diff --git a/functions/messages/guardduty_finding.json b/functions/messages/guardduty_finding.json new file mode 100644 index 00000000..313a743c --- /dev/null +++ b/functions/messages/guardduty_finding.json @@ -0,0 +1,20 @@ +{ + "Records": [{ + "EventSource": "aws:sns", + "EventVersion": "1.0", + "EventSubscriptionArn": "arn:aws:sns:us-gov-east-1::ExampleTopic", + "Sns": { + "Type": "Notification", + "MessageId": "95df01b4-ee98-5cb9-9903-4c221d41eb5e", + "TopicArn": "arn:aws:sns:us-gov-east-1:123456789012:ExampleTopic", + "Subject": "GuardDuty Finding", + "Message": "{\"detail-type\": \"GuardDuty Finding\",\"region\": \"us-gov-east-1\",\"detail\": {\"id\": \"sample-id-2\",\"title\": \"SAMPLE Unprotected port on EC2 instance i-123123123 is being probed\",\"severity\": 9,\"description\": \"EC2 instance has an unprotected port which is being probed by a known malicious host.\",\"type\": \"Recon:EC2 PortProbeUnprotectedPort\",\"service\": {\"eventFirstSeen\": \"2020-01-02T01:02:03Z\",\"eventLastSeen\": \"2020-01-03T01:02:03Z\",\"count\": 1234}}}", + "Timestamp": "1970-01-01T00:00:00.000Z", + "SignatureVersion": "1", + "Signature": "EXAMPLE", + "SigningCertUrl": "EXAMPLE", + "UnsubscribeUrl": "EXAMPLE", + "MessageAttributes": {} + } + }] +} diff --git a/functions/messages/text_message.json b/functions/messages/text_message.json new file mode 100644 index 00000000..b7c2873c --- /dev/null +++ b/functions/messages/text_message.json @@ -0,0 +1,20 @@ +{ + "Records": [{ + "EventSource": "aws:sns", + "EventVersion": "1.0", + "EventSubscriptionArn": "arn:aws:sns:us-gov-west-1::ExampleTopic", + "Sns": { + "Type": "Notification", + "MessageId": "f86e3c5b-cd17-1ab8-80e9-c0776d4f1e7a", + "TopicArn": "arn:aws:sns:us-gov-west-1:123456789012:ExampleTopic", + "Subject": "All Fine", + "Message": "This\nis\na typical multi-line\nmessage from SNS!\n\nHave a ~good~ amazing day! :)", + "Timestamp": "2019-02-12T15:45:24.091Z", + "SignatureVersion": "1", + "Signature": "EXAMPLE", + "SigningCertUrl": "EXAMPLE", + "UnsubscribeUrl": "EXAMPLE", + "MessageAttributes": {} + } + }] +} diff --git a/functions/notify_slack.py b/functions/notify_slack.py index 5f1bc5a9..ca9be290 100644 --- a/functions/notify_slack.py +++ b/functions/notify_slack.py @@ -1,161 +1,332 @@ -from __future__ import print_function -from urllib.error import HTTPError -import os, boto3, json, base64 -import urllib.request, urllib.parse +# -*- coding: utf-8 -*- +""" + Notify Slack + ------------ + + Receives event payloads that are parsed and sent to Slack + +""" + +import base64 +import json import logging +import os +import urllib.parse +import urllib.request +from enum import Enum +from typing import Any, Dict, Optional, Union, cast +from urllib.error import HTTPError + +import boto3 + +# Set default region if not provided +REGION = os.environ.get("AWS_REGION", "us-east-1") + +# Create client so its cached/frozen between invocations +KMS_CLIENT = boto3.client("kms", region_name=REGION) + +class AwsService(Enum): + """AWS service supported by function""" -# Decrypt encrypted URL with KMS -def decrypt(encrypted_url): - region = os.environ['AWS_REGION'] - try: - kms = boto3.client('kms', region_name=region) - plaintext = kms.decrypt(CiphertextBlob=base64.b64decode(encrypted_url))['Plaintext'] - return plaintext.decode() - except Exception: - logging.exception("Failed to decrypt URL with KMS") - - -def cloudwatch_notification(message, region): - states = {'OK': 'good', 'INSUFFICIENT_DATA': 'warning', 'ALARM': 'danger'} - if region.startswith("us-gov-"): - cloudwatch_url = "https://console.amazonaws-us-gov.com/cloudwatch/home?region=" - else: - cloudwatch_url = "https://console.aws.amazon.com/cloudwatch/home?region=" - - return { - "color": states[message['NewStateValue']], - "fallback": "Alarm {} triggered".format(message['AlarmName']), - "fields": [ - { "title": "Alarm Name", "value": message['AlarmName'], "short": True }, - { "title": "Alarm Description", "value": message['AlarmDescription'], "short": False}, - { "title": "Alarm reason", "value": message['NewStateReason'], "short": False}, - { "title": "Old State", "value": message['OldStateValue'], "short": True }, - { "title": "Current State", "value": message['NewStateValue'], "short": True }, - { - "title": "Link to Alarm", - "value": cloudwatch_url + region + "#alarm:alarmFilter=ANY;name=" + urllib.parse.quote(message['AlarmName']), - "short": False - } - ] - } - - -def guardduty_finding(message, region): - states = {'Low': '#777777', 'Medium': 'warning', 'High': 'danger'} - if region.startswith("us-gov-"): - guardduty_url = "https://console.amazonaws-us-gov.com/guardduty/home?region=" - else: - guardduty_url = "https://console.aws.amazon.com/guardduty/home?region=" - - if message['detail']['severity'] < 4.0: - severity = 'Low' - elif message['detail']['severity'] < 7.0: - severity = 'Medium' - else: - severity = 'High' - - return { - "color": states[severity], - "fallback": "GuardDuty Finding: {}".format(message['detail']['title']), - "fields": [ - {"title": "Description", "value": message['detail']['description'], "short": False }, - {"title": "Finding type", "value": message['detail']['type'], "short": False}, - {"title": "First Seen", "value": message['detail']['service']['eventFirstSeen'], "short": True}, - {"title": "Last Seen", "value": message['detail']['service']['eventLastSeen'], "short": True}, - {"title": "Severity", "value": severity, "short": True}, - {"title": "Count", "value": message['detail']['service']['count'], "short": True}, - { - "title": "Link to Finding", - "value": guardduty_url + region + "#/findings?search=id%3D" + message['detail']['id'], - "short": False - } - ] - } - - -def default_notification(subject, message): - attachments = { - "fallback": "A new message", - "title": subject if subject else "Message", - "mrkdwn_in": ["value"], - "fields": [] - } - if type(message) is dict: - for k, v in message.items(): - value = f"`{json.dumps(v)}`" if isinstance(v, (dict, list)) else str(v) - attachments['fields'].append( - { - "title": k, - "value": value, - "short": len(value) < 25 - } - ) - else: - attachments['fields'].append({"value": message, "short": False}) - - return attachments - - -# Send a message to a slack channel -def notify_slack(subject, message, region): - slack_url = os.environ['SLACK_WEBHOOK_URL'] - if not slack_url.startswith("http"): - slack_url = decrypt(slack_url) - - slack_channel = os.environ['SLACK_CHANNEL'] - slack_username = os.environ['SLACK_USERNAME'] - slack_emoji = os.environ['SLACK_EMOJI'] - - payload = { - "channel": slack_channel, - "username": slack_username, - "icon_emoji": slack_emoji, - "attachments": [] - } - - if type(message) is str: + cloudwatch = "cloudwatch" + guardduty = "guardduty" + + +def decrypt_url(encrypted_url: str) -> str: + """Decrypt encrypted URL with KMS + + :param encrypted_url: URL to decrypt with KMS + :returns: plaintext URL + """ try: - message = json.loads(message) - except json.JSONDecodeError as err: - logging.exception(f'JSON decode error: {err}') - - if "AlarmName" in message: - notification = cloudwatch_notification(message, region) - payload['text'] = "AWS CloudWatch notification - " + message["AlarmName"] - payload['attachments'].append(notification) - elif "detail-type" in message and message["detail-type"] == "GuardDuty Finding": - notification = guardduty_finding(message, message["region"]) - payload['text'] = "Amazon GuardDuty Finding - " + message["detail"]["title"] - payload['attachments'].append(notification) - elif "attachments" in message or "text" in message: - payload = {**payload, **message} - else: - payload['text'] = "AWS notification" - payload['attachments'].append(default_notification(subject, message)) - - data = urllib.parse.urlencode({"payload": json.dumps(payload)}).encode("utf-8") - req = urllib.request.Request(slack_url) - - try: - result = urllib.request.urlopen(req, data) - return json.dumps({"code": result.getcode(), "info": result.info().as_string()}) - - except HTTPError as e: - logging.error("{}: result".format(e)) - return json.dumps({"code": e.getcode(), "info": e.info().as_string()}) - - -def lambda_handler(event, context): - if 'LOG_EVENTS' in os.environ and os.environ['LOG_EVENTS'] == 'True': - logging.warning('Event logging enabled: `{}`'.format(json.dumps(event))) - - subject = event['Records'][0]['Sns']['Subject'] - message = event['Records'][0]['Sns']['Message'] - region = event['Records'][0]['Sns']['TopicArn'].split(":")[3] - response = notify_slack(subject, message, region) - - if json.loads(response)["code"] != 200: - logging.error("Error: received status `{}` using event `{}` and context `{}`".format(json.loads(response)["info"], event, context)) - - return response + decrypted_payload = KMS_CLIENT.decrypt( + CiphertextBlob=base64.b64decode(encrypted_url) + ) + return decrypted_payload["Plaintext"].decode() + except Exception: + logging.exception("Failed to decrypt URL with KMS") + return "" + + +def get_service_url(region: str, service: str) -> str: + """Get the appropriate service URL for the region + + :param region: name of the AWS region + :param service: name of the AWS service + :returns: AWS console url formatted for the region and service provided + """ + try: + service_name = AwsService[service].value + if region.startswith("us-gov-"): + return f"https://console.amazonaws-us-gov.com/{service_name}/home?region={region}" + else: + return f"https://console.aws.amazon.com/{service_name}/home?region={region}" + + except KeyError: + print(f"Service {service} is currently not supported") + raise + + +class CloudWatchAlarmState(Enum): + """Maps CloudWatch notification state to Slack message format color""" + + OK = "good" + INSUFFICIENT_DATA = "warning" + ALARM = "danger" + + +def format_cloudwatch_alarm(message: Dict[str, Any], region: str) -> Dict[str, Any]: + """Format CloudWatch alarm event into Slack message format + + :params message: SNS message body containing CloudWatch alarm event + :region: AWS region where the event originated from + :returns: formatted Slack message payload + """ + + cloudwatch_url = get_service_url(region=region, service="cloudwatch") + alarm_name = message["AlarmName"] + + return { + "color": CloudWatchAlarmState[message["NewStateValue"]].value, + "fallback": f"Alarm {alarm_name} triggered", + "fields": [ + {"title": "Alarm Name", "value": f"`{alarm_name}`", "short": True}, + { + "title": "Alarm Description", + "value": f"`{message['AlarmDescription']}`", + "short": False, + }, + { + "title": "Alarm reason", + "value": f"`{message['NewStateReason']}`", + "short": False, + }, + { + "title": "Old State", + "value": f"`{message['OldStateValue']}`", + "short": True, + }, + { + "title": "Current State", + "value": f"`{message['NewStateValue']}`", + "short": True, + }, + { + "title": "Link to Alarm", + "value": f"{cloudwatch_url}#alarm:alarmFilter=ANY;name={urllib.parse.quote(alarm_name)}", + "short": False, + }, + ], + "text": f"AWS CloudWatch notification - {message['AlarmName']}", + } + + +class GuardDutyFindingSeverity(Enum): + """Maps GuardDuty finding severity to Slack message format color""" + + Low = "#777777" + Medium = "warning" + High = "danger" + + +def format_guardduty_finding(message: Dict[str, Any], region: str) -> Dict[str, Any]: + """ + Format GuardDuty finding event into Slack message format + + :params message: SNS message body containing GuardDuty finding event + :params region: AWS region where the event originated from + :returns: formatted Slack message payload + """ + + guardduty_url = get_service_url(region=region, service="guardduty") + detail = message["detail"] + service = detail.get("service", {}) + severity_score = detail.get("severity") + + if severity_score < 4.0: + severity = "Low" + elif severity_score < 7.0: + severity = "Medium" + else: + severity = "High" + + return { + "color": GuardDutyFindingSeverity[severity].value, + "fallback": f"GuardDuty Finding: {detail.get('title')}", + "fields": [ + { + "title": "Description", + "value": f"`{detail['description']}`", + "short": False, + }, + { + "title": "Finding Type", + "value": f"`{detail['type']}`", + "short": False, + }, + { + "title": "First Seen", + "value": f"`{service['eventFirstSeen']}`", + "short": True, + }, + { + "title": "Last Seen", + "value": f"`{service['eventLastSeen']}`", + "short": True, + }, + {"title": "Severity", "value": f"`{severity}`", "short": True}, + { + "title": "Count", + "value": f"`{service['count']}`", + "short": True, + }, + { + "title": "Link to Finding", + "value": f"{guardduty_url}#/findings?search=id%3D{detail['id']}", + "short": False, + }, + ], + "text": f"AWS GuardDuty Finding - {detail.get('title')}", + } + + +def format_default( + message: Union[str, Dict], subject: Optional[str] = None +) -> Dict[str, Any]: + """ + Default formatter, converting event into Slack message format + + :params message: SNS message body containing message/event + :returns: formatted Slack message payload + """ + + attachments = { + "fallback": "A new message", + "text": "AWS notification", + "title": subject if subject else "Message", + "mrkdwn_in": ["value"], + } + fields = [] + + if type(message) is dict: + for k, v in message.items(): + value = f"{json.dumps(v)}" if isinstance(v, (dict, list)) else str(v) + fields.append({"title": k, "value": f"`{value}`", "short": len(value) < 25}) + else: + fields.append({"value": message, "short": False}) + + if fields: + attachments["fields"] = fields # type: ignore + + return attachments + + +def get_slack_message_payload( + message: Union[str, Dict], region: str, subject: Optional[str] = None +) -> Dict: + """ + Parse notification message and format into Slack message payload + + :params message: SNS message body notification payload + :params region: AWS region where the event originated from + :params subject: Optional subject line for Slack notification + :returns: Slack message payload + """ + + slack_channel = os.environ["SLACK_CHANNEL"] + slack_username = os.environ["SLACK_USERNAME"] + slack_emoji = os.environ["SLACK_EMOJI"] + + payload = { + "channel": slack_channel, + "username": slack_username, + "icon_emoji": slack_emoji, + } + attachment = None + + if isinstance(message, str): + try: + message = json.loads(message) + except json.JSONDecodeError: + logging.info("Not a structured payload, just a string message") + + message = cast(Dict[str, Any], message) + + if "AlarmName" in message: + notification = format_cloudwatch_alarm(message=message, region=region) + attachment = notification + + elif ( + isinstance(message, Dict) and message.get("detail-type") == "GuardDuty Finding" + ): + notification = format_guardduty_finding( + message=message, region=message["region"] + ) + attachment = notification + + elif "attachments" in message or "text" in message: + payload = {**payload, **message} + + else: + attachment = format_default(message=message, subject=subject) + + if attachment: + payload["attachments"] = [attachment] # type: ignore + + return payload + + +def send_slack_notification(payload: Dict[str, Any]) -> str: + """ + Send notification payload to Slack + + :params payload: formatted Slack message payload + :returns: response details from sending notification + """ + + slack_url = os.environ["SLACK_WEBHOOK_URL"] + if not slack_url.startswith("http"): + slack_url = decrypt_url(slack_url) + + data = urllib.parse.urlencode({"payload": json.dumps(payload)}).encode("utf-8") + req = urllib.request.Request(slack_url) + + try: + result = urllib.request.urlopen(req, data) + return json.dumps({"code": result.getcode(), "info": result.info().as_string()}) + + except HTTPError as e: + logging.error(f"{e}: result") + return json.dumps({"code": e.getcode(), "info": e.info().as_string()}) + + +def lambda_handler(event: Dict[str, Any], context: Dict[str, Any]) -> str: + """ + Lambda function to parse notification events and forward to Slack + + :param event: lambda expected event object + :param context: lambda expected context object + :returns: none + """ + if os.environ.get("LOG_EVENTS", "False") == "True": + logging.info(f"Event logging enabled: `{json.dumps(event)}`") + + for record in event["Records"]: + sns = record["Sns"] + subject = sns["Subject"] + message = sns["Message"] + region = sns["TopicArn"].split(":")[3] + + payload = get_slack_message_payload( + message=message, region=region, subject=subject + ) + response = send_slack_notification(payload=payload) + + if json.loads(response)["code"] != 200: + response_info = json.loads(response)["info"] + logging.error( + f"Error: received status `{response_info}` using event `{event}` and context `{context}`" + ) + + return response diff --git a/functions/notify_slack_test.py b/functions/notify_slack_test.py index c1933803..0971aa83 100644 --- a/functions/notify_slack_test.py +++ b/functions/notify_slack_test.py @@ -1,204 +1,153 @@ -#!/usr/bin/env pytest +# -*- coding: utf-8 -*- +""" + Slack Notification Test + ----------------------- + + Unit tests for `notify_slack.py` + +""" + +import ast +import os import notify_slack import pytest -from json import loads -from os import environ - -events = ( - ( - { - "Records": [ - { - "EventSource": "aws:sns", - "EventVersion": "1.0", - "EventSubscriptionArn": "arn:aws:sns:eu-west-2:735598076380:service-updates:d29b4e2c-6840-9c4e-ceac-17128efcc337", - "Sns": { - "Type": "Notification", - "MessageId": "f86e3c5b-cd17-1ab8-80e9-c0776d4f1e7a", - "TopicArn": "arn:aws:sns:eu-west-2:735598076380:service-updates", - "Subject": "OK: \"DBMigrationRequired\" in EU (London)", - "Message": "{\"AlarmName\":\"DBMigrationRequired\",\"AlarmDescription\":\"App is reporting \\\"A JPA error occurred (Unable to build EntityManagerFactory)\\\"\",\"AWSAccountId\":\"735598076380\",\"NewStateValue\":\"OK\",\"NewStateReason\":\"Threshold Crossed: 1 datapoint [1.0 (12/02/19 15:44:00)] was not less than the threshold (1.0).\",\"StateChangeTime\":\"2019-02-12T15:45:24.006+0000\",\"Region\":\"EU (London)\",\"OldStateValue\":\"ALARM\",\"Trigger\":{\"MetricName\":\"DBMigrationRequired\",\"Namespace\":\"LogMetrics\",\"StatisticType\":\"Statistic\",\"Statistic\":\"SUM\",\"Unit\":null,\"Dimensions\":[],\"Period\":60,\"EvaluationPeriods\":1,\"ComparisonOperator\":\"LessThanThreshold\",\"Threshold\":1.0,\"TreatMissingData\":\"- TreatMissingData: NonBreaching\",\"EvaluateLowSampleCountPercentile\":\"\"}}", - "Timestamp": "2019-02-12T15:45:24.091Z", - "SignatureVersion": "1", - "Signature": "WMYdVRN7ECNXMWZ0faRDD4fSfALW5MISB6O//LMd/LeSQYNQ/1eKYEE0PM1SHcH+73T/f/eVHbID/F203VZaGECQTD4LVA4B0DGAEY39LVbWdPTCHIDC6QCBV5ScGFZcROBXMe3UBWWMQAVTSWTE0eP526BFUTecaDFM4b9HMT4NEHWa4A2TA7d888JaVKKdSVNTd4bGS6Q2XFG1MOb652BRAHdARO7A6//2/47JZ5COM6LR0/V7TcOYCBZ20CRF6L5XLU46YYL3I1PNGKbEC1PIeVDVJVPcA17NfUbFXWYBX8LHfM4O7ZbGAPaGffDYLFWM6TX1Y6fQ01OSMc21OdUGV6HQR01e%==", - "SigningCertUrl": "https://sns.eu-west-2.amazonaws.com/SimpleNotificationService-7dd85a2b76adaa8dd603b7a0c9150589.pem", - "UnsubscribeUrl": "https://sns.eu-west-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:eu-west-2:735598076380:service-updates:d29b4e2c-6840-9c4e-ceac-17128efcc337", - "MessageAttributes": {} - } - } - ] - } - ), - ( - { - "Records": [ - { - "EventSource": "aws:sns", - "EventVersion": "1.0", - "EventSubscriptionArn": "arn:aws:sns:eu-west-2:735598076380:service-updates:d29b4e2c-6840-9c4e-ceac-17128efcc337", - "Sns": { - "Type": "Notification", - "MessageId": "f86e3c5b-cd17-1ab8-80e9-c0776d4f1e7a", - "TopicArn": "arn:aws:sns:eu-west-2:735598076380:service-updates", - "Subject": "All Fine", - "Message": "This\nis\na typical multi-line\nmessage from SNS!\n\nHave a ~good~ amazing day! :)", - "Timestamp": "2019-02-12T15:45:24.091Z", - "SignatureVersion": "1", - "Signature": "WMYdVRN7ECNXMWZ0faRDD4fSfALW5MISB6O//LMd/LeSQYNQ/1eKYEE0PM1SHcH+73T/f/eVHbID/F203VZaGECQTD4LVA4B0DGAEY39LVbWdPTCHIDC6QCBV5ScGFZcROBXMe3UBWWMQAVTSWTE0eP526BFUTecaDFM4b9HMT4NEHWa4A2TA7d888JaVKKdSVNTd4bGS6Q2XFG1MOb652BRAHdARO7A6//2/47JZ5COM6LR0/V7TcOYCBZ20CRF6L5XLU46YYL3I1PNGKbEC1PIeVDVJVPcA17NfUbFXWYBX8LHfM4O7ZbGAPaGffDYLFWM6TX1Y6fQ01OSMc21OdUGV6HQR01e%==", - "SigningCertUrl": "https://sns.eu-west-2.amazonaws.com/SimpleNotificationService-7dd85a2b76adaa8dd603b7a0c9150589.pem", - "UnsubscribeUrl": "https://sns.eu-west-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:eu-west-2:735598076380:service-updates:d29b4e2c-6840-9c4e-ceac-17128efcc337", - "MessageAttributes": {} - } - } - ] - } - ), - ( - { - "Records": [ - { - "EventSource": "aws:sns", - "EventVersion": "1.0", - "EventSubscriptionArn": "arn:aws:sns:eu-west-2:735598076380:service-updates:d29b4e2c-6840-9c4e-ceac-17128efcc337", - "Sns": { - "Type": "Notification", - "MessageId": "f86e3c5b-cd17-1ab8-80e9-c0776d4f1e7a", - "TopicArn": "arn:aws:sns:eu-west-2:735598076380:service-updates", - "Subject": "DMS Notification Message", - "Message": "{\"Event Source\": \"replication-task\", \"Event Time\": \"2019-02-12 15:45:24.091\", \"Identifier Link\": \"https://console.aws.amazon.com/dms/home?region=eu-west-2#tasks:ids=hello-world\\nSourceId: hello-world \", \"Event ID\": \"http://docs.aws.amazon.com/dms/latest/userguide/CHAP_Events.html#DMS-EVENT-0079 \", \"Event Message\": \"Replication task has stopped.\"}", - "Timestamp": "2019-02-12T15:45:24.091Z", - "SignatureVersion": "1", - "Signature": "WMYdVRN7ECNXMWZ0faRDD4fSfALW5MISB6O//LMd/LeSQYNQ/1eKYEE0PM1SHcH+73T/f/eVHbID/F203VZaGECQTD4LVA4B0DGAEY39LVbWdPTCHIDC6QCBV5ScGFZcROBXMe3UBWWMQAVTSWTE0eP526BFUTecaDFM4b9HMT4NEHWa4A2TA7d888JaVKKdSVNTd4bGS6Q2XFG1MOb652BRAHdARO7A6//2/47JZ5COM6LR0/V7TcOYCBZ20CRF6L5XLU46YYL3I1PNGKbEC1PIeVDVJVPcA17NfUbFXWYBX8LHfM4O7ZbGAPaGffDYLFWM6TX1Y6fQ01OSMc21OdUGV6HQR01e%==", - "SigningCertUrl": "https://sns.eu-west-2.amazonaws.com/SimpleNotificationService-7dd85a2b76adaa8dd603b7a0c9150589.pem", - "UnsubscribeUrl": "https://sns.eu-west-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:eu-west-2:735598076380:service-updates:d29b4e2c-6840-9c4e-ceac-17128efcc337", - "MessageAttributes": {} - } - } - ] - } - ), - ( - { - "AlarmName": "Example", - "AlarmDescription": "Example alarm description.", - "AWSAccountId": "000000000000", - "NewStateValue": "ALARM", - "NewStateReason": "Threshold Crossed", - "StateChangeTime": "2017-01-12T16:30:42.236+0000", - "Region": "EU - Ireland", - "OldStateValue": "OK" - } - ), - ( - { - "AlarmType": "Unsupported alarm type", - "AWSAccountId": "000000000000", - "NewStateValue": "ALARM", - } - ), - ( - { - "attachments": [ - { - "mrkdwn_in": ["text"], - "color": "#36a64f", - "pretext": "Optional pre-text that appears above the attachment block", - "author_name": "author_name", - "author_link": "http://flickr.com/bobby/", - "author_icon": "https://placeimg.com/16/16/people", - "title": "title", - "title_link": "https://api.slack.com/", - "text": "Optional `text` that appears within the attachment", - "fields": [ - { - "title": "A field's title", - "value": "This field's value", - "short": False - }, - { - "title": "A short field's title", - "value": "A short field's value", - "short": True - }, - { - "title": "A second short field's title", - "value": "A second short field's value", - "short": True - } - ], - "thumb_url": "http://placekitten.com/g/200/200", - "footer": "footer", - "footer_icon": "https://platform.slack-edge.com/img/default_application_icon.png", - "ts": 123456789 - } - ] - } - ), - ( - { - "Records": [ - { - "EventSource": "aws:sns", - "EventVersion": "1.0", - "EventSubscriptionArn": "arn:aws:sns:eu-west-1:000000000000:my-sns:76cc1745-b910-4f5e-97bf-f5993b044420", - "Sns": { - "Type": "Notification", - "MessageId": "00337b3f-0982-5cb1-9138-22799c885da9", - "TopicArn": "arn:aws:sns:eu-west-1:000000000000:my-sns", - "Subject": None, - "Message": '{"version":"0","id":"ad3c3da1-148c-d5da-9a6a-79f1bc9a8a2e","detail-type":"Glue Job State Change","source":"aws.glue","account":"000000000000","time":"2021-06-18T12:34:06Z","region":"eu-west-1","resources":[],"detail":{"jobName":"test_job","severity":"ERROR","state":"FAILED","jobRunId":"jr_ca2144d747b45ad412d3c66a1b6934b6b27aa252be9a21a95c54dfaa224a1925","message":"SystemExit: 1"}}', - "Timestamp": "2021-06-18T12:34:09.509Z", - "SignatureVersion": "1", - "Signature": "MN9H4+7QXISx+IqoRtsdIIXhd9cy9yIV916ajnDChJF9XaPi76zlwHb6RYRdi8MxKIEZsQ7F6DYV/4Hz6GqcQckqZpuYywwa3S1qUim4jw+HKtVvLAsQr/aZ0n2b/8gBC0wPpge3YaMJ13iliJ0G5Bs85MoCrTVG17TGsg8HqJkeKNx1mC4PyOMejXm+F3dwudPLozJ+CX6s+rMkiHVmpJjAv9N2qYgCKloG//dXQEU9LdZpGTDFEnazVR8PKjBEN9RTXNcNnAWuFrt+r0kOtiUoObtJOulPrUIQhIi8fvLto329wWzUQkB9wnvEt7QHeO9Qp8WhstQ3/ki8yiyAwA==", - "SigningCertUrl": "https://sns.eu-west-1.amazonaws.com/SimpleNotificationService-010a507c1833636cd94bdb98bd93083a.pem", - "UnsubscribeUrl": "https://sns.eu-west-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:eu-west-1:r00000000000:my-sns:76cc1745-b910-4f5e-97bf-f5993b044420", - "MessageAttributes": {}, - }, - } - ] - } - ), - ( - { - "detail-type": "GuardDuty Finding", - "region": "us-east-1", - "detail": { - "id": "sample-id-2", - "title": "SAMPLE Unprotected port on EC2 instance i-123123123 is being probed", - "severity": 9, - "description": "EC2 instance has an unprotected port which is being probed by a known malicious host.", - "type": "Recon:EC2/PortProbeUnprotectedPort", - "service": { - "eventFirstSeen":"2020-01-02T01:02:03Z", - "eventLastSeen":"2020-01-03T01:02:03Z", - "count": 1234 - } - }, - } - ), -) -@pytest.fixture(scope='module', autouse=True) -def check_environment_variables(): - required_environment_variables = ("SLACK_CHANNEL", "SLACK_EMOJI", "SLACK_USERNAME", "SLACK_WEBHOOK_URL") - missing_environment_variables = [] - for k in required_environment_variables: - if k not in environ: - missing_environment_variables.append(k) +def test_sns_get_slack_message_payload_snapshots(snapshot, monkeypatch): + """ + Compare outputs of get_slack_message_payload() with snapshots stored + + Run `pipenv run test:updatesnapshots` to update snapshot images + """ + + monkeypatch.setenv("SLACK_CHANNEL", "slack_testing_sandbox") + monkeypatch.setenv("SLACK_USERNAME", "notify_slack_test") + monkeypatch.setenv("SLACK_EMOJI", ":aws:") + + # These are SNS messages that invoke the lambda handler; the event payload is in the + # `message` field + _dir = "./messages" + messages = [f for f in os.listdir(_dir) if os.path.isfile(os.path.join(_dir, f))] + + for file in messages: + with open(os.path.join(_dir, file), "r") as ofile: + event = ast.literal_eval(ofile.read()) + + attachments = [] + # These are as delivered wrapped in an SNS message payload so we unpack + for record in event["Records"]: + sns = record["Sns"] + subject = sns["Subject"] + message = sns["Message"] + region = sns["TopicArn"].split(":")[3] + + attachment = notify_slack.get_slack_message_payload( + message=message, region=region, subject=subject + ) + attachments.append(attachment) + + filename = os.path.basename(file) + snapshot.assert_match(attachments, f"message_{filename}") + + +def test_event_get_slack_message_payload_snapshots(snapshot, monkeypatch): + """ + Compare outputs of get_slack_message_payload() with snapshots stored + + Run `pipenv run test:updatesnapshots` to update snapshot images + """ - if len(missing_environment_variables) > 0: - pytest.exit('Missing environment variables: {}'.format(", ".join(missing_environment_variables))) + monkeypatch.setenv("SLACK_CHANNEL", "slack_testing_sandbox") + monkeypatch.setenv("SLACK_USERNAME", "notify_slack_test") + monkeypatch.setenv("SLACK_EMOJI", ":aws:") + # These are just the raw events that will be converted to JSON string and + # sent via SNS message + _dir = "./events" + events = [f for f in os.listdir(_dir) if os.path.isfile(os.path.join(_dir, f))] -@pytest.mark.parametrize("event", events) -def test_lambda_handler(event): - if 'Records' in event: - response = notify_slack.lambda_handler(event, 'self-context') + for file in events: + with open(os.path.join(_dir, file), "r") as ofile: + event = ast.literal_eval(ofile.read()) + + attachment = notify_slack.get_slack_message_payload( + message=event, region="us-east-1", subject="bar" + ) + attachments = [attachment] + + filename = os.path.basename(file) + snapshot.assert_match(attachments, f"event_{filename}") + + +def test_environment_variables_set(monkeypatch): + """ + Should pass since environment variables are provided + """ + + monkeypatch.setenv("SLACK_CHANNEL", "slack_testing_sandbox") + monkeypatch.setenv("SLACK_USERNAME", "notify_slack_test") + monkeypatch.setenv("SLACK_EMOJI", ":aws:") + monkeypatch.setenv( + "SLACK_WEBHOOK_URL", "https://hooks.slack.com/services/YOUR/WEBOOK/URL" + ) + + with open(os.path.join("./messages/text_message.json"), "r") as efile: + event = ast.literal_eval(efile.read()) + + for record in event["Records"]: + sns = record["Sns"] + subject = sns["Subject"] + message = sns["Message"] + region = sns["TopicArn"].split(":")[3] + + notify_slack.get_slack_message_payload( + message=message, region=region, subject=subject + ) + + +def test_environment_variables_missing(): + """ + Should pass since environment variables are NOT provided and + will raise a `KeyError` + """ + with pytest.raises(KeyError): + # will raise before parsing/validation + notify_slack.get_slack_message_payload(message={}, region="foo", subject="bar") + + +@pytest.mark.parametrize( + "region,service,expected", + [ + ( + "us-east-1", + "cloudwatch", + "https://console.aws.amazon.com/cloudwatch/home?region=us-east-1", + ), + ( + "us-gov-east-1", + "cloudwatch", + "https://console.amazonaws-us-gov.com/cloudwatch/home?region=us-gov-east-1", + ), + ( + "us-east-1", + "guardduty", + "https://console.aws.amazon.com/guardduty/home?region=us-east-1", + ), + ( + "us-gov-east-1", + "guardduty", + "https://console.amazonaws-us-gov.com/guardduty/home?region=us-gov-east-1", + ), + ], +) +def test_get_service_url(region, service, expected): + assert notify_slack.get_service_url(region=region, service=service) == expected - else: - response = notify_slack.notify_slack('subject', event, 'eu-west-1') - response = loads(response) - assert response['code'] == 200 +def test_get_service_url_exception(): + """ + Should raise error since service is not defined in enum + """ + with pytest.raises(KeyError): + notify_slack.get_service_url(region="us-east-1", service="athena") diff --git a/functions/pytest.ini.sample b/functions/pytest.ini.sample deleted file mode 100644 index 2d107355..00000000 --- a/functions/pytest.ini.sample +++ /dev/null @@ -1,7 +0,0 @@ -[pytest] -addopts = --disable-pytest-warnings -env = - SLACK_CHANNEL=slack_testing_sandbox - SLACK_EMOJI=:aws: - SLACK_USERNAME=notify_slack_test - SLACK_WEBHOOK_URL=https://hooks.slack.com/services/YOUR/WEBOOK/URL diff --git a/functions/requirements.txt b/functions/requirements.txt deleted file mode 100644 index cf01492a..00000000 --- a/functions/requirements.txt +++ /dev/null @@ -1,4 +0,0 @@ -boto3 - -pytest -pytest-env diff --git a/functions/snapshots/__init__.py b/functions/snapshots/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/functions/snapshots/snap_notify_slack_test.py b/functions/snapshots/snap_notify_slack_test.py new file mode 100644 index 00000000..11d9316d --- /dev/null +++ b/functions/snapshots/snap_notify_slack_test.py @@ -0,0 +1,383 @@ +# -*- coding: utf-8 -*- +# snapshottest: v1 - https://goo.gl/zC4yUc +from __future__ import unicode_literals + +from snapshottest import Snapshot + +snapshots = Snapshot() + +snapshots[ + "test_event_get_slack_message_payload_snapshots event_cloudwatch_alarm.json" +] = [ + { + "attachments": [ + { + "color": "danger", + "fallback": "Alarm Example triggered", + "fields": [ + {"short": True, "title": "Alarm Name", "value": "`Example`"}, + { + "short": False, + "title": "Alarm Description", + "value": "`Example alarm description.`", + }, + { + "short": False, + "title": "Alarm reason", + "value": "`Threshold Crossed`", + }, + {"short": True, "title": "Old State", "value": "`OK`"}, + {"short": True, "title": "Current State", "value": "`ALARM`"}, + { + "short": False, + "title": "Link to Alarm", + "value": "https://console.aws.amazon.com/cloudwatch/home?region=us-east-1#alarm:alarmFilter=ANY;name=Example", + }, + ], + "text": "AWS CloudWatch notification - Example", + } + ], + "channel": "slack_testing_sandbox", + "icon_emoji": ":aws:", + "username": "notify_slack_test", + } +] + +snapshots[ + "test_event_get_slack_message_payload_snapshots event_guardduty_finding_high.json" +] = [ + { + "attachments": [ + { + "color": "danger", + "fallback": "GuardDuty Finding: SAMPLE Unprotected port on EC2 instance i-123123123 is being probed", + "fields": [ + { + "short": False, + "title": "Description", + "value": "`EC2 instance has an unprotected port which is being probed by a known malicious host.`", + }, + { + "short": False, + "title": "Finding Type", + "value": "`Recon:EC2 PortProbeUnprotectedPort`", + }, + { + "short": True, + "title": "First Seen", + "value": "`2020-01-02T01:02:03Z`", + }, + { + "short": True, + "title": "Last Seen", + "value": "`2020-01-03T01:02:03Z`", + }, + {"short": True, "title": "Severity", "value": "`High`"}, + {"short": True, "title": "Count", "value": "`1234`"}, + { + "short": False, + "title": "Link to Finding", + "value": "https://console.aws.amazon.com/guardduty/home?region=us-east-1#/findings?search=id%3Dsample-id-2", + }, + ], + "text": "AWS GuardDuty Finding - SAMPLE Unprotected port on EC2 instance i-123123123 is being probed", + } + ], + "channel": "slack_testing_sandbox", + "icon_emoji": ":aws:", + "username": "notify_slack_test", + } +] + +snapshots[ + "test_event_get_slack_message_payload_snapshots event_guardduty_finding_low.json" +] = [ + { + "attachments": [ + { + "color": "#777777", + "fallback": "GuardDuty Finding: SAMPLE Unprotected port on EC2 instance i-123123123 is being probed", + "fields": [ + { + "short": False, + "title": "Description", + "value": "`EC2 instance has an unprotected port which is being probed by a known malicious host.`", + }, + { + "short": False, + "title": "Finding Type", + "value": "`Recon:EC2 PortProbeUnprotectedPort`", + }, + { + "short": True, + "title": "First Seen", + "value": "`2020-01-02T01:02:03Z`", + }, + { + "short": True, + "title": "Last Seen", + "value": "`2020-01-03T01:02:03Z`", + }, + {"short": True, "title": "Severity", "value": "`Low`"}, + {"short": True, "title": "Count", "value": "`1234`"}, + { + "short": False, + "title": "Link to Finding", + "value": "https://console.aws.amazon.com/guardduty/home?region=us-east-1#/findings?search=id%3Dsample-id-2", + }, + ], + "text": "AWS GuardDuty Finding - SAMPLE Unprotected port on EC2 instance i-123123123 is being probed", + } + ], + "channel": "slack_testing_sandbox", + "icon_emoji": ":aws:", + "username": "notify_slack_test", + } +] + +snapshots[ + "test_event_get_slack_message_payload_snapshots event_guardduty_finding_medium.json" +] = [ + { + "attachments": [ + { + "color": "warning", + "fallback": "GuardDuty Finding: SAMPLE Unprotected port on EC2 instance i-123123123 is being probed", + "fields": [ + { + "short": False, + "title": "Description", + "value": "`EC2 instance has an unprotected port which is being probed by a known malicious host.`", + }, + { + "short": False, + "title": "Finding Type", + "value": "`Recon:EC2 PortProbeUnprotectedPort`", + }, + { + "short": True, + "title": "First Seen", + "value": "`2020-01-02T01:02:03Z`", + }, + { + "short": True, + "title": "Last Seen", + "value": "`2020-01-03T01:02:03Z`", + }, + {"short": True, "title": "Severity", "value": "`Medium`"}, + {"short": True, "title": "Count", "value": "`1234`"}, + { + "short": False, + "title": "Link to Finding", + "value": "https://console.aws.amazon.com/guardduty/home?region=us-east-1#/findings?search=id%3Dsample-id-2", + }, + ], + "text": "AWS GuardDuty Finding - SAMPLE Unprotected port on EC2 instance i-123123123 is being probed", + } + ], + "channel": "slack_testing_sandbox", + "icon_emoji": ":aws:", + "username": "notify_slack_test", + } +] + +snapshots[ + "test_sns_get_slack_message_payload_snapshots message_cloudwatch_alarm.json" +] = [ + { + "attachments": [ + { + "color": "good", + "fallback": "Alarm DBMigrationRequired triggered", + "fields": [ + { + "short": True, + "title": "Alarm Name", + "value": "`DBMigrationRequired`", + }, + { + "short": False, + "title": "Alarm Description", + "value": '`App is reporting "A JPA error occurred(Unable to build EntityManagerFactory)"`', + }, + { + "short": False, + "title": "Alarm reason", + "value": "`Threshold Crossed: 1 datapoint [1.0 (12/02/19 15:44:00)] was not less than the threshold (1.0).`", + }, + {"short": True, "title": "Old State", "value": "`ALARM`"}, + {"short": True, "title": "Current State", "value": "`OK`"}, + { + "short": False, + "title": "Link to Alarm", + "value": "https://console.aws.amazon.com/cloudwatch/home?region=us-east-1#alarm:alarmFilter=ANY;name=DBMigrationRequired", + }, + ], + "text": "AWS CloudWatch notification - DBMigrationRequired", + } + ], + "channel": "slack_testing_sandbox", + "icon_emoji": ":aws:", + "username": "notify_slack_test", + } +] + +snapshots[ + "test_sns_get_slack_message_payload_snapshots message_dms_notification.json" +] = [ + { + "attachments": [ + { + "fallback": "A new message", + "fields": [ + { + "short": True, + "title": "Event Source", + "value": "`replication-task`", + }, + { + "short": True, + "title": "Event Time", + "value": "`2019-02-12 15:45:24.091`", + }, + { + "short": False, + "title": "Identifier Link", + "value": "`https://console.aws.amazon.com/dms/home?region=us-east-1#tasks:ids=hello-world`", + }, + {"short": True, "title": "SourceId", "value": "`hello-world`"}, + { + "short": False, + "title": "Event ID", + "value": "`http://docs.aws.amazon.com/dms/latest/userguide/CHAP_Events.html#DMS-EVENT-0079 `", + }, + { + "short": False, + "title": "Event Message", + "value": "`Replication task has stopped.`", + }, + ], + "mrkdwn_in": ["value"], + "text": "AWS notification", + "title": "DMS Notification Message", + } + ], + "channel": "slack_testing_sandbox", + "icon_emoji": ":aws:", + "username": "notify_slack_test", + } +] + +snapshots[ + "test_sns_get_slack_message_payload_snapshots message_glue_notification.json" +] = [ + { + "attachments": [ + { + "fallback": "A new message", + "fields": [ + {"short": True, "title": "version", "value": "`0`"}, + { + "short": False, + "title": "id", + "value": "`ad3c3da1-148c-d5da-9a6a-79f1bc9a8a2e`", + }, + { + "short": True, + "title": "detail-type", + "value": "`Glue Job State Change`", + }, + {"short": True, "title": "source", "value": "`aws.glue`"}, + {"short": True, "title": "account", "value": "`000000000000`"}, + {"short": True, "title": "time", "value": "`2021-06-18T12:34:06Z`"}, + {"short": True, "title": "region", "value": "`us-east-2`"}, + {"short": True, "title": "resources", "value": "`[]`"}, + { + "short": False, + "title": "detail", + "value": '`{"jobName": "test_job", "severity": "ERROR", "state": "FAILED", "jobRunId": "jr_ca2144d747b45ad412d3c66a1b6934b6b27aa252be9a21a95c54dfaa224a1925", "message": "SystemExit: 1"}`', + }, + ], + "mrkdwn_in": ["value"], + "text": "AWS notification", + "title": "Message", + } + ], + "channel": "slack_testing_sandbox", + "icon_emoji": ":aws:", + "username": "notify_slack_test", + } +] + +snapshots[ + "test_sns_get_slack_message_payload_snapshots message_guardduty_finding.json" +] = [ + { + "attachments": [ + { + "color": "danger", + "fallback": "GuardDuty Finding: SAMPLE Unprotected port on EC2 instance i-123123123 is being probed", + "fields": [ + { + "short": False, + "title": "Description", + "value": "`EC2 instance has an unprotected port which is being probed by a known malicious host.`", + }, + { + "short": False, + "title": "Finding Type", + "value": "`Recon:EC2 PortProbeUnprotectedPort`", + }, + { + "short": True, + "title": "First Seen", + "value": "`2020-01-02T01:02:03Z`", + }, + { + "short": True, + "title": "Last Seen", + "value": "`2020-01-03T01:02:03Z`", + }, + {"short": True, "title": "Severity", "value": "`High`"}, + {"short": True, "title": "Count", "value": "`1234`"}, + { + "short": False, + "title": "Link to Finding", + "value": "https://console.amazonaws-us-gov.com/guardduty/home?region=us-gov-east-1#/findings?search=id%3Dsample-id-2", + }, + ], + "text": "AWS GuardDuty Finding - SAMPLE Unprotected port on EC2 instance i-123123123 is being probed", + } + ], + "channel": "slack_testing_sandbox", + "icon_emoji": ":aws:", + "username": "notify_slack_test", + } +] + +snapshots["test_sns_get_slack_message_payload_snapshots message_text_message.json"] = [ + { + "attachments": [ + { + "fallback": "A new message", + "fields": [ + { + "short": False, + "value": """This +is +a typical multi-line +message from SNS! + +Have a ~good~ amazing day! :)""", + } + ], + "mrkdwn_in": ["value"], + "text": "AWS notification", + "title": "All Fine", + } + ], + "channel": "slack_testing_sandbox", + "icon_emoji": ":aws:", + "username": "notify_slack_test", + } +]