From b56a39c43c96c2b08bcb16e254c43fc5e1c7db45 Mon Sep 17 00:00:00 2001 From: Eric Nguyen Date: Sun, 12 Dec 2021 21:32:00 +0700 Subject: [PATCH] initial commit --- .editorconfig | 16 ++ .gitignore | 10 + README.md | 58 ++++++ app.py | 32 +++ .../count-failed-sign-in-attempt/index.py | 60 ++++++ .../requirements.txt | 0 .../slack-notification/index.py | 117 +++++++++++ .../slack-notification/requirements.txt | 1 + .../store-sign-in-activity/index.py | 40 ++++ .../store-sign-in-activity/requirements.txt | 0 cdk.json | 10 + database/__init__.py | 1 + database/infra.py | 28 +++ login/__init__.py | 1 + login/infra.py | 191 ++++++++++++++++++ notification/__init__.py | 1 + notification/infra.py | 67 ++++++ requirements-dev.txt | 1 + requirements.txt | 1 + tests/__init__.py | 0 tests/unit/__init__.py | 0 .../unit/test_aws_activity_tracking_stack.py | 15 ++ 22 files changed, 650 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.py create mode 100644 assets/lambda-functions/count-failed-sign-in-attempt/index.py create mode 100644 assets/lambda-functions/count-failed-sign-in-attempt/requirements.txt create mode 100644 assets/lambda-functions/slack-notification/index.py create mode 100644 assets/lambda-functions/slack-notification/requirements.txt create mode 100644 assets/lambda-functions/store-sign-in-activity/index.py create mode 100644 assets/lambda-functions/store-sign-in-activity/requirements.txt create mode 100644 cdk.json create mode 100644 database/__init__.py create mode 100644 database/infra.py create mode 100644 login/__init__.py create mode 100644 login/infra.py create mode 100644 notification/__init__.py create mode 100644 notification/infra.py create mode 100644 requirements-dev.txt create mode 100644 requirements.txt create mode 100644 tests/__init__.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_aws_activity_tracking_stack.py diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..edfb7d8 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,16 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[**/*.md] +trim_trailing_whitespace = false + +[**/*.py] +indent_size = 4 +indent_style = space diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..37833f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.swp +package-lock.json +__pycache__ +.pytest_cache +.venv +*.egg-info + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/README.md b/README.md new file mode 100644 index 0000000..17cb0bb --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ + +# Welcome to your CDK Python project! + +This is a blank project for Python development with CDK. + +The `cdk.json` file tells the CDK Toolkit how to execute your app. + +This project is set up like a standard Python project. The initialization +process also creates a virtualenv within this project, stored under the `.venv` +directory. To create the virtualenv it assumes that there is a `python3` +(or `python` for Windows) executable in your path with access to the `venv` +package. If for any reason the automatic creation of the virtualenv fails, +you can create the virtualenv manually. + +To manually create a virtualenv on MacOS and Linux: + +``` +$ python3 -m venv .venv +``` + +After the init process completes and the virtualenv is created, you can use the following +step to activate your virtualenv. + +``` +$ source .venv/bin/activate +``` + +If you are a Windows platform, you would activate the virtualenv like this: + +``` +% .venv\Scripts\activate.bat +``` + +Once the virtualenv is activated, you can install the required dependencies. + +``` +$ pip install -r requirements.txt +``` + +At this point you can now synthesize the CloudFormation template for this code. + +``` +$ cdk synth +``` + +To add additional dependencies, for example other CDK libraries, just add +them to your `setup.py` file and rerun the `pip install -r requirements.txt` +command. + +## Useful commands + + * `cdk ls` list all stacks in the app + * `cdk synth` emits the synthesized CloudFormation template + * `cdk deploy` deploy this stack to your default AWS account/region + * `cdk diff` compare deployed stack with current state + * `cdk docs` open CDK documentation + +Enjoy! diff --git a/app.py b/app.py new file mode 100644 index 0000000..7cf8726 --- /dev/null +++ b/app.py @@ -0,0 +1,32 @@ +#!/usr/bin/env python3 +import os + +import aws_cdk as cdk + +from database import AwsActivityDatabaseStack +from notification import AwsActivityNotificationStack +from login import AwsSignInActivityStack + + +app = cdk.App() + +us_east_1 = cdk.Environment(region=os.environ["CDK_DEFAULT_REGION"], account=os.getenv('CDK_DEFAULT_ACCOUNT')) + +# database +db_stack = AwsActivityDatabaseStack(app, 'aws-activity-db', env=us_east_1, + description='Tracking AWS activities for security compliance' +) + +# notification +notification_stack = AwsActivityNotificationStack(app, 'aws-activity-notification', env=us_east_1, + description='Notify AWS activities for security compliance' +) + +# sign-in activity +AwsSignInActivityStack(app, 'aws-sign-in-activity', env=us_east_1, + dynamodb_table=db_stack.table, + notification_topic=notification_stack.topic, + description='Tracking AWS sign-in activities for security compliance' +) + +app.synth() diff --git a/assets/lambda-functions/count-failed-sign-in-attempt/index.py b/assets/lambda-functions/count-failed-sign-in-attempt/index.py new file mode 100644 index 0000000..b5f754b --- /dev/null +++ b/assets/lambda-functions/count-failed-sign-in-attempt/index.py @@ -0,0 +1,60 @@ +import logging +import time +import boto3 +import os +from boto3.dynamodb.conditions import Key, Attr + + +logging.basicConfig(level=logging.DEBUG) +logger=logging.getLogger(__name__) + +def handler(event, context): + logger.setLevel(logging.DEBUG) + + TABLE_NAME = os.getenv('DYNAMODB_TABLE_NAME') + user_identity_index = { + 'name': 'UserIdentityIndex', + 'hash_key': 'userIdentity', + 'sort_key': 'timestamp' + } + + + user_identity = event['userIdentity'] + + # last 1 hour = now - 1h + # seconds + now = int(time.time()) + last_one_hour = now - 1 * 60 * 60 + + # table = boto3.resource('dynamodb', region_name='us-east-1').Table(TABLE_NAME) + table = boto3.resource('dynamodb').Table(TABLE_NAME) + results = [] + last_evaluated_key = None + + while True: + if not last_evaluated_key: + response = table.query( + IndexName=user_identity_index['name'], + KeyConditionExpression=Key(user_identity_index['hash_key']).eq(user_identity) & Key(user_identity_index['sort_key']).between(last_one_hour, now), + FilterExpression=Attr('eventName').eq('ConsoleLogin') & Attr('detail.responseElements.ConsoleLogin').eq('Failure'), + ScanIndexForward=False, + ) + else: + response = table.query( + IndexName=user_identity_index['name'], + KeyConditionExpression=Key(user_identity_index['hash_key']).eq(user_identity) & Key(user_identity_index['sort_key']).between(last_one_hour, now), + FilterExpression=Attr('eventName').eq('ConsoleLogin') & Attr('detail.responseElements.ConsoleLogin').eq('Failure'), + ScanIndexForward=False, + ExclusiveStartKey=last_evaluated_key + ) + results.extend(response['Items']) + + last_evaluated_key = response.get('LastEvaluatedKey') + if not last_evaluated_key: + break + logger.info(len(results)) + + event['failedAttempts'] = len(results) + logger.debug(event) + + return event diff --git a/assets/lambda-functions/count-failed-sign-in-attempt/requirements.txt b/assets/lambda-functions/count-failed-sign-in-attempt/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/assets/lambda-functions/slack-notification/index.py b/assets/lambda-functions/slack-notification/index.py new file mode 100644 index 0000000..22527a8 --- /dev/null +++ b/assets/lambda-functions/slack-notification/index.py @@ -0,0 +1,117 @@ +import logging +import requests +import json +from datetime import datetime + + +logging.basicConfig(level=logging.DEBUG) +logger=logging.getLogger(__name__) + +SLACK_CHANNELS = { + 'alarm-aws': 'https://hooks.slack.com/services/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' +} + +def handler(event, context): + logger.setLevel(logging.DEBUG) + logger.debug(event) + + message = json.loads(event['Records'][0]['Sns']['Message']) + message_attributes = event['Records'][0]['Sns']['MessageAttributes'] + + fields = [] + + text = 'See detail below' + reason = message_attributes['reason']['Value'] + if reason == 'ManyFailedSignInAttempt': + text = 'There are many failed sign-in attempts in last one hour' + fields.extend([ + { + 'title': 'User', + "value": message['detail']['userIdentity']['userName'], + 'short': True + }, + { + 'title': 'Failed attempt', + "value": message['failedAttempts'], + 'short': True + } + ]) + elif reason == 'NoMFAUsed': + text = 'Detected MFA not used' + fields.extend([ + { + 'title': 'User', + "value": message['detail']['userIdentity']['userName'], + 'short': True + }, + { + 'title': 'Event name', + "value": message['eventName'], + 'short': True + } + ]) + elif reason == 'RootActivity': + text = 'Detected Root activity' + fields.extend([ + { + 'title': 'User', + "value": 'Root', + 'short': True + }, + { + 'title': 'Event name', + "value": message['eventName'], + 'short': True + } + ]) + + fields.extend([ + { + 'title': 'IP address', + "value": message['detail']['sourceIPAddress'], + 'short': True + }, + { + 'title': 'Severity', + "value": message_attributes['severity']['Value'], + 'short': True + }, + { + 'title': 'Event ID', + "value": message['id'], + 'short': True + } + ]) + + send_slack( + channel=message_attributes['channel']['Value'], + title=text, + fields=fields, + severity=message_attributes['severity']['Value'] + ) + +def send_slack(channel: str, title: str, fields: list, severity='Medium'): + color = '#36a64f' + if severity == 'Medium': + color = '#edaf2b' + elif severity == 'High': + color = '#cc5f00' + elif severity == 'Critical': + color = '#cc0000' + + payload = { + 'username': 'Cloud Guard', + 'attachments': [{ + 'title': title, + 'color': color, + 'fields': fields, + 'footer': datetime.now().strftime('%Y-%m-%d %H:%M:%S UTC'), + 'fallback': 'Required plain-text summary of the attachment.' + }] + } + if severity.lower() == 'critical': + payload['attachments'][0].update({'pretext': ''}) + + logger.info(payload) + requests.post(SLACK_CHANNELS.get(channel), data=json.dumps(payload), headers={'Content-Type': 'application/json'}) + logger.info('Send Slack notify successfully') diff --git a/assets/lambda-functions/slack-notification/requirements.txt b/assets/lambda-functions/slack-notification/requirements.txt new file mode 100644 index 0000000..4f5b899 --- /dev/null +++ b/assets/lambda-functions/slack-notification/requirements.txt @@ -0,0 +1 @@ +requests==2.26.0 \ No newline at end of file diff --git a/assets/lambda-functions/store-sign-in-activity/index.py b/assets/lambda-functions/store-sign-in-activity/index.py new file mode 100644 index 0000000..684dcf8 --- /dev/null +++ b/assets/lambda-functions/store-sign-in-activity/index.py @@ -0,0 +1,40 @@ +import logging +import time +from dateutil import parser +import boto3 +import os +import json + + +logging.basicConfig(level=logging.DEBUG) +logger=logging.getLogger(__name__) + +def handler(event, context): + # logger.setLevel(logging.DEBUG) + + # 365 days + ttl = int(time.time()) + 365 * 24 * 60 * 60 + timestamp = int(parser.parse(event['time']).timestamp()) + + event_detail = event['detail'] + event_name = event_detail['eventName'] + user_identity_type = event_detail['userIdentity']['type'] + + user_identity = f'{user_identity_type}#Unknown' + if user_identity_type == 'IAMUser': + user_identity = f'{user_identity_type}-{event_detail["userIdentity"]["userName"]}' + elif user_identity_type == 'Root': + user_identity = f'{user_identity_type}#Root' + elif user_identity == 'AssumedRole': + user_identity = f'{user_identity_type}#{event_detail["userIdentity"]["sessionContext"]["sessionIssuer"]["userName"]}' + + event = {**event, 'eventName': event_name, 'userIdentity': user_identity, 'timestamp': timestamp, 'ttl': ttl} + logger.debug(json.dumps(event)) + + # dynamodb = boto3.resource('dynamodb', region_name='us-east-1') + dynamodb = boto3.resource('dynamodb') + table = dynamodb.Table(os.getenv('DYNAMODB_TABLE_NAME')) + table.put_item(Item=event) + logger.info(f'Activity has been stored into database successfully') + + return event diff --git a/assets/lambda-functions/store-sign-in-activity/requirements.txt b/assets/lambda-functions/store-sign-in-activity/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/cdk.json b/cdk.json new file mode 100644 index 0000000..4a964f7 --- /dev/null +++ b/cdk.json @@ -0,0 +1,10 @@ +{ + "app": "python3 app.py", + "context": { + "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true, + "@aws-cdk/core:stackRelativeExports": true, + "@aws-cdk/aws-rds:lowercaseDbIdentifier": true, + "@aws-cdk/aws-lambda:recognizeVersionProps": true, + "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": true + } +} diff --git a/database/__init__.py b/database/__init__.py new file mode 100644 index 0000000..11a1afe --- /dev/null +++ b/database/__init__.py @@ -0,0 +1 @@ +from .infra import AwsActivityDatabaseStack \ No newline at end of file diff --git a/database/infra.py b/database/infra.py new file mode 100644 index 0000000..68c4a70 --- /dev/null +++ b/database/infra.py @@ -0,0 +1,28 @@ +from aws_cdk import ( + Stack, + aws_dynamodb as dynamodb +) +from constructs import Construct + + +class AwsActivityDatabaseStack(Stack): + def __init__(self, scope: Construct, id: str, **kwargs) -> None: + super().__init__(scope, id, **kwargs) + + # dynamodb + self.table = dynamodb.Table(self, 'DynamoDB', + table_name='aws-activity', + partition_key=dynamodb.Attribute(name='id', type=dynamodb.AttributeType.STRING), + sort_key=dynamodb.Attribute(name='timestamp', type=dynamodb.AttributeType.NUMBER), + time_to_live_attribute='ttl', + billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST + ) + + # DynamoDB GSI + self.table.add_global_secondary_index( + index_name='UserIdentityIndex', + partition_key=dynamodb.Attribute(name='userIdentity', type=dynamodb.AttributeType.STRING), + sort_key=dynamodb.Attribute(name='timestamp', type=dynamodb.AttributeType.NUMBER), + non_key_attributes=['id', 'eventName', 'detail'], + projection_type=dynamodb.ProjectionType.INCLUDE + ) diff --git a/login/__init__.py b/login/__init__.py new file mode 100644 index 0000000..23eaeea --- /dev/null +++ b/login/__init__.py @@ -0,0 +1 @@ +from .infra import AwsSignInActivityStack \ No newline at end of file diff --git a/login/infra.py b/login/infra.py new file mode 100644 index 0000000..0b4f204 --- /dev/null +++ b/login/infra.py @@ -0,0 +1,191 @@ +from aws_cdk import ( + Stack, + Duration, + RemovalPolicy, + aws_dynamodb as dynamodb, + aws_stepfunctions as sfn, + aws_stepfunctions_tasks as tasks, + aws_logs as logs, + aws_lambda as lambda_, + aws_iam as iam, + aws_events as events, + aws_events_targets as events_targets, + aws_sns as sns +) +from constructs import Construct + + +class AwsSignInActivityStack(Stack): + def __init__(self, scope: Construct, id: str, dynamodb_table: dynamodb.ITable, notification_topic: sns.ITopic, **kwargs) -> None: + super().__init__(scope, id, **kwargs) + + # Cloudwatch log + log_group = logs.LogGroup(self, 'CloudWatchLogGroup', + log_group_name='aws-sign-in-activity', + retention=logs.RetentionDays.ONE_MONTH, + removal_policy=RemovalPolicy.DESTROY + ) + + # IAM role + role = iam.Role(self, 'Role', + role_name='aws-sign-in-activity', + description='Role for functions related to aws-sign-in activities', + assumed_by=iam.ServicePrincipal('lambda.amazonaws.com'), + inline_policies={ + 'CloudwatchLog': iam.PolicyDocument(statements=[ + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=[ + 'logs:CreateLogGroup' + ], + resources=[f'arn:aws:logs:{self.region}:{self.account}:*'] + ), + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=['logs:CreateLogStream', 'logs:PutLogEvents'], + resources=[f'arn:aws:logs:{self.region}:{self.account}:log-group:/aws/lambda/*:*'] + ) + ]), + 'DynamoDBWrite': iam.PolicyDocument(statements=[ + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=['dynamodb:BatchWriteItem', 'dynamodb:PutItem', 'dynamodb:UpdateItem'], + resources=[dynamodb_table.table_arn] + ) + ]), + 'DynamoDBRead': iam.PolicyDocument(statements=[ + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=['dynamodb:BatchGetItem', 'dynamodb:GetItem', 'dynamodb:Scan', 'dynamodb:Query'], + resources=[dynamodb_table.table_arn, f'{dynamodb_table.table_arn}/index/*'] + ) + ]) + } + ) + + # Step function + store_job = tasks.LambdaInvoke(self, 'Store activity', + lambda_function=lambda_.Function(self, 'StoreActivityFunction', + function_name='store-aws-sign-in-activity', + handler='index.handler', + runtime=lambda_.Runtime.PYTHON_3_9, + description='Store AWS sign-in activities into DynamoDB', + code=lambda_.Code.from_asset( + path='assets/lambda-functions/store-sign-in-activity' + ), + environment={ + 'DYNAMODB_TABLE_NAME': dynamodb_table.table_name + }, + timeout=Duration.seconds(15), + memory_size=128, + role=role + ), + output_path='$.Payload' + ) + + succeed_job = sfn.Succeed(self, 'Do nothing') + + count_failed_sign_in_job = tasks.LambdaInvoke(self, 'Count failed sign-in attempt', + lambda_function=lambda_.Function(self, 'CountFailedSignInFunction', + function_name='count-failed-sign-in-attempt', + handler='index.handler', + runtime=lambda_.Runtime.PYTHON_3_9, + description='Count failed AWS sign-in attempts', + code=lambda_.Code.from_asset( + path='assets/lambda-functions/count-failed-sign-in-attempt' + ), + environment={ + 'DYNAMODB_TABLE_NAME': dynamodb_table.table_name + }, + timeout=Duration.minutes(3), + memory_size=128, + role=role + ), + output_path='$.Payload' + ).next( + sfn.Choice(self, 'More than 2 attempts?').when( + condition=sfn.Condition.number_greater_than('$.failedAttempts', 2), + next=tasks.SnsPublish(self, 'Alert on many failed sign-in attempts', + topic=notification_topic, + message=sfn.TaskInput.from_json_path_at('$'), + message_attributes={ + 'severity': tasks.MessageAttribute(value='High'), + 'reason': tasks.MessageAttribute(value='ManyFailedSignInAttempt'), + 'targets': tasks.MessageAttribute(value=['Slack']), + 'channel': tasks.MessageAttribute(value='alarm-aws') + } + ) + ).otherwise(succeed_job) + ) + + check_mfa = sfn.Choice(self, 'MFA used?').when( + condition=sfn.Condition.string_equals('$.detail.additionalEventData.MFAUsed', 'Yes'), + next=succeed_job + ).otherwise( + tasks.SnsPublish(self, 'Alert on no MFA', + topic=notification_topic, + message=sfn.TaskInput.from_json_path_at('$'), + message_attributes={ + 'severity': tasks.MessageAttribute(value='Medium'), + 'reason': tasks.MessageAttribute(value='NoMFAUsed'), + 'targets': tasks.MessageAttribute(value=['Slack']), + 'channel': tasks.MessageAttribute(value='alarm-aws') + } + ) + ) + + check_login = sfn.Choice(self, 'Login failed?').when( + condition=sfn.Condition.string_equals('$.detail.responseElements.ConsoleLogin', 'Failure'), + next=count_failed_sign_in_job + ).otherwise(check_mfa) + + sign_in_activity = sfn.Choice(self, 'Sign-in activity?').when( + sfn.Condition.string_equals('$.eventName', 'ConsoleLogin'), + next=check_login + ).otherwise(succeed_job) + + check_root_user = sfn.Choice(self, 'Root user?').when( + condition=sfn.Condition.string_equals('$.detail.userIdentity.type', 'Root'), + next=tasks.SnsPublish(self, 'Alert on Root activity', + topic=notification_topic, + message=sfn.TaskInput.from_json_path_at('$'), + message_attributes={ + 'severity': tasks.MessageAttribute(value='Critical'), + 'reason': tasks.MessageAttribute(value='RootActivity'), + 'targets': tasks.MessageAttribute(value=['Slack']), + 'channel': tasks.MessageAttribute(value='alarm-aws') + } + ) + ).when( + sfn.Condition.string_equals('$.detail.userIdentity.type', 'IAMUser'), + sign_in_activity + ) + + definition = store_job.next(check_root_user) + + state_machine = sfn.StateMachine(self, 'StepFunction', + state_machine_name='aws-sign-in-activity', + state_machine_type=sfn.StateMachineType.STANDARD, + definition=definition, + logs=sfn.LogOptions( + destination=log_group, + level=sfn.LogLevel.ERROR + ) + ) + + # event bridge + events.Rule(self, 'EventBridgeRule', + rule_name='aws-sign-in-activity', + description='Rule of AWS sign-in activities', + event_pattern=events.EventPattern( + detail_type=[ + 'AWS API Call via CloudTrail', + 'AWS Console Sign In via CloudTrail' + ], + source=['aws.signin'] + ), + targets=[events_targets.SfnStateMachine( + machine=state_machine, + retry_attempts=3 + )] + ) diff --git a/notification/__init__.py b/notification/__init__.py new file mode 100644 index 0000000..c00dc87 --- /dev/null +++ b/notification/__init__.py @@ -0,0 +1 @@ +from .infra import AwsActivityNotificationStack \ No newline at end of file diff --git a/notification/infra.py b/notification/infra.py new file mode 100644 index 0000000..98c5641 --- /dev/null +++ b/notification/infra.py @@ -0,0 +1,67 @@ +from aws_cdk import ( + Stack, + Duration, + aws_sns as sns, + aws_sns_subscriptions as subscriptions, + aws_lambda as lambda_, + aws_iam as iam +) +from constructs import Construct + + +class AwsActivityNotificationStack(Stack): + def __init__(self, scope: Construct, id: str, **kwargs) -> None: + super().__init__(scope, id, **kwargs) + + # IAM role + role = iam.Role(self, 'Role', + role_name='aws-activity-notification', + description='Role for functions related to aws-activity-notification', + assumed_by=iam.ServicePrincipal('lambda.amazonaws.com'), + inline_policies={ + 'CloudwatchLog': iam.PolicyDocument(statements=[ + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=[ + 'logs:CreateLogGroup' + ], + resources=[f'arn:aws:logs:{self.region}:{self.account}:*'] + ), + iam.PolicyStatement( + effect=iam.Effect.ALLOW, + actions=['logs:CreateLogStream', 'logs:PutLogEvents'], + resources=[f'arn:aws:logs:{self.region}:{self.account}:log-group:/aws/lambda/*:*'] + ) + ]) + } + ) + + slack_notify_function=lambda_.Function(self, 'SlackNotifyFunction', + function_name='aws-activity-slack-notify', + handler='index.handler', + runtime=lambda_.Runtime.PYTHON_3_9, + description='Notify to Slack for AWS activities', + code=lambda_.Code.from_asset( + path='assets/lambda-functions/slack-notification' + ), + timeout=Duration.minutes(2), + memory_size=128, + role=role + ) + + self.topic = sns.Topic(self, 'SnsTopic', + topic_name='aws-activity-notification' + ) + + # Lambda should receive only message matching the following conditions on attributes: + # target: 'Slack' or 'slack' or begins with 'bl' + # channel: attribute must be present + self.topic.add_subscription(subscriptions.LambdaSubscription( + fn=slack_notify_function, + filter_policy={ + "targets": sns.SubscriptionFilter.string_filter( + allowlist=["Slack", "slack"] + ), + "channel": sns.SubscriptionFilter.exists_filter() + } + )) diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..9270945 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1 @@ +pytest==6.2.5 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e2dcbc7 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +aws-cdk-lib==2.1.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/test_aws_activity_tracking_stack.py b/tests/unit/test_aws_activity_tracking_stack.py new file mode 100644 index 0000000..09fc8ad --- /dev/null +++ b/tests/unit/test_aws_activity_tracking_stack.py @@ -0,0 +1,15 @@ +import aws_cdk as core +import aws_cdk.assertions as assertions + +from aws_activity_tracking.aws_activity_tracking_stack import AwsActivityTrackingStack + +# example tests. To run these tests, uncomment this file along with the example +# resource in aws_activity_tracking/aws_activity_tracking_stack.py +def test_sqs_queue_created(): + app = core.App() + stack = AwsActivityTrackingStack(app, "aws-activity-tracking") + template = assertions.Template.from_stack(stack) + +# template.has_resource_properties("AWS::SQS::Queue", { +# "VisibilityTimeout": 300 +# })