├── .gitignore ├── README.md ├── cat_defaults.tf ├── data.tf ├── iam.tf ├── lambda.tf ├── lambda └── main.py ├── logging.tf ├── terraform.tf ├── trigger.tf └── variables.tf /.gitignore: -------------------------------------------------------------------------------- 1 | # Local .terraform directories 2 | **/.terraform/* 3 | 4 | # .tfstate files 5 | *.tfstate 6 | *.tfstate.* 7 | 8 | # Crash log files 9 | crash.log 10 | 11 | # Ignore any .tfvars files that are generated automatically for each Terraform run. Most 12 | # .tfvars files are managed as part of configuration and so should be included in 13 | # version control. 14 | # 15 | # example.tfvars 16 | 17 | # Ignore override files as they are usually used to override resources locally and so 18 | # are not checked in 19 | override.tf 20 | override.tf.json 21 | *_override.tf 22 | *_override.tf.json 23 | 24 | # Include override files you do wish to add to version control using negated pattern 25 | # 26 | # !example_override.tf 27 | 28 | # Include tfplan files to ignore the plan output of command: terraform plan -out=tfplan 29 | # example: *tfplan* 30 | 31 | .terraform.lock.hcl 32 | 33 | lambda.zip 34 | 35 | terraform.tfvars 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **NOTE** Standalone terraform module [here](https://github.com/cloudandthings/terraform-aws-clickops-notifier) 2 | 3 | # AWS ClickOops watcher for Slack 4 | This deployment allows you to monitor your AWS accounts for changes being made in the console. 5 | 6 | ## Prerequisites 7 | 1. The solution has been built to be used in an AWS multi-account environment provisioned using [AWS Control Tower](https://aws.amazon.com/controltower). In Control Tower all CloudTrail logs are shipped to a central Log Archive account which simplifies the processing of these logs. 8 | 9 | 2. Additionally you will need a [Slack app](https://api.slack.com/apps) with an incoming webhook configured. 10 | 11 | ## Post deployment 12 | After deploying the solution you will need to set the SSM parameter containing the Slack Webhook URL manually. This is not set in code for security reasons. 13 | 14 | ## Terraform 15 | ### Requirements 16 | 17 | | Name | Version | 18 | |------|---------| 19 | | aws | 3.49.0 | 20 | 21 | ### Providers 22 | 23 | | Name | Version | 24 | |------|---------| 25 | | archive | n/a | 26 | | aws | 3.49.0 | 27 | | aws.reference | 3.49.0 | 28 | | null | n/a | 29 | 30 | ### Inputs 31 | 32 | | Name | Description | Type | Default | Required | 33 | |------|-------------|------|---------|:--------:| 34 | | application\_name | Used in naming conventions, expecting an object |
object({
short = string
long = string
})
| n/a | yes | 35 | | aws\_account\_id | Needed for Guards to ensure code is being deployed to the correct account | `string` | n/a | yes | 36 | | client\_name | Used in naming conventions, expecting an object |
object({
short = string
long = string
})
| n/a | yes | 37 | | cloudtrail\_bucket | Bucket containing the Cloudtrail logs that you want to process. |
object({
name = string
arn = string
})
| n/a | yes | 38 | | code\_repo | Points to the source code used to deploy the resources {{repo}} [{{branch}}] | `string` | n/a | yes | 39 | | environment | Will this deploy a development (dev) or production (prod) environment | `string` | n/a | yes | 40 | | event\_processing\_timeout | Maximum number of seconds the lambda is allowed to run and number of seconds events should be hidden in SQS after being picked up my Lambda. | `number` | `60` | no | 41 | | excluded\_accounts | List of accounts that be excluded for scans on manual actions. | `list(string)` | `[]` | no | 42 | | included\_accounts | List of accounts that be scanned to manual actions. | `list(string)` | `[]` | no | 43 | | log\_retention\_in\_days | Number of days to keep CloudWatch logs | `number` | `30` | no | 44 | | namespace | Used to identify which part of the application these resources belong to (auth, infra, api, web, data) | `string` | n/a | yes | 45 | | nukeable | Can these resources be cleaned up. Will be ignored for prod environments | `bool` | n/a | yes | 46 | | owner | Used to find resources owners, expects an email address | `string` | n/a | yes | 47 | | purpose | Used for cost allocation purposes | `string` | n/a | yes | 48 | | region | The default region for the application / deployment | `string` | n/a | yes | 49 | | tags | Tags added to all resources, this will be added to the list of mandatory tags | `map(string)` | n/a | yes | 50 | 51 | ### Sample terraform.tfvars 52 | 53 | ```hcl 54 | cloudtrail_bucket = { 55 | name = "aws-controltower-logs-XXX-eu-west-1" 56 | arn = "arn:aws:s3:::aws-controltower-logs-XXX-eu-west-1" 57 | } 58 | 59 | region = "eu-west-1" 60 | environment = "prd" 61 | code_repo = "github.com:phzietsman/aws-slack-clickoops-watcher" 62 | namespace = "sec" 63 | application_name = { short : "clkop", long : "clickoops" } 64 | nukeable = false 65 | client_name = { short : "cat", long : "cloudandthings" } 66 | purpose = "self" 67 | owner = "paul@cloudandthings.io" 68 | aws_account_id = "xxx" 69 | tags = { 70 | "description" : "Part of the solution to check whether we are using the AWS Console to manage our resourcese." 71 | } 72 | ``` 73 | ## Credits 74 | https://arkadiyt.com/2019/11/12/detecting-manual-aws-console-actions/ 75 | 76 | https://towardsdatascience.com/protect-your-infrastructure-with-real-time-notifications-of-aws-console-user-changes-3144fd18c680 77 | -------------------------------------------------------------------------------- /cat_defaults.tf: -------------------------------------------------------------------------------- 1 | # _____ ___ _____ ______ ___________ ___ _ _ _ _____ _____ 2 | # / __ \ / _ \_ _| | _ \ ___| ___/ _ \| | | | | |_ _/ ___| 3 | # | / \// /_\ \| | | | | | |__ | |_ / /_\ \ | | | | | | \ `--. 4 | # | | | _ || | | | | | __|| _|| _ | | | | | | | `--. \ 5 | # | \__/\| | | || | | |/ /| |___| | | | | | |_| | |____| | /\__/ / 6 | # \____/\_| |_/\_/ |___/ \____/\_| \_| |_/\___/\_____/\_/ \____/ 7 | # 8 | # https://patorjk.com/software/taag/#p=display&f=Doom&t=DEFAULTS 9 | # 10 | # 11 | 12 | variable "region" { 13 | type = string 14 | description = "The default region for the application / deployment" 15 | 16 | validation { 17 | condition = contains([ 18 | "eu-central-1", 19 | "eu-west-1", 20 | "eu-west-2", 21 | "eu-south-1", 22 | "eu-west-3", 23 | "eu-north-1", 24 | "af-south-1" 25 | ], var.region) 26 | error_message = "Invalid region provided." 27 | } 28 | } 29 | 30 | variable "environment" { 31 | type = string 32 | description = "Will this deploy a development (dev) or production (prod) environment" 33 | 34 | validation { 35 | condition = contains(["dev", "prd"], var.environment) 36 | error_message = "Stage must be either 'dev' or 'prd'." 37 | } 38 | } 39 | 40 | variable "code_repo" { 41 | type = string 42 | description = "Points to the source code used to deploy the resources {{repo}} [{{branch}}]" 43 | } 44 | 45 | variable "namespace" { 46 | type = string 47 | description = "Used to identify which part of the application these resources belong to (auth, infra, api, web, data)" 48 | 49 | validation { 50 | condition = contains(["sec", "auth", "infra", "api", "web", "data"], var.namespace) 51 | error_message = "Namespace needs to be : \"sec\", \"auth\", \"infra\", \"api\" or \"web\"." 52 | } 53 | } 54 | 55 | variable "application_name" { 56 | type = object({ 57 | short = string 58 | long = string 59 | }) 60 | description = "Used in naming conventions, expecting an object" 61 | 62 | validation { 63 | condition = length(var.application_name["short"]) <= 5 64 | error_message = "The application_name[\"short\"] needs to be less or equal to 5 chars." 65 | } 66 | } 67 | 68 | variable "nukeable" { 69 | type = bool 70 | description = "Can these resources be cleaned up. Will be ignored for prod environments" 71 | } 72 | 73 | variable "client_name" { 74 | type = object({ 75 | short = string 76 | long = string 77 | }) 78 | description = "Used in naming conventions, expecting an object" 79 | 80 | validation { 81 | condition = length(var.client_name["short"]) <= 5 82 | error_message = "The client_name[\"short\"] needs to be less or equal to 5 chars." 83 | } 84 | } 85 | 86 | variable "purpose" { 87 | type = string 88 | description = "Used for cost allocation purposes" 89 | 90 | validation { 91 | condition = contains(["rnd", "client", "product", "self"], var.purpose) 92 | error_message = "Purpose needs to be : \"rnd\", \"client\", \"product\", \"self\"." 93 | } 94 | } 95 | 96 | variable "owner" { 97 | type = string 98 | description = "Used to find resources owners, expects an email address" 99 | 100 | validation { 101 | condition = can(regex("^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$", var.owner)) 102 | error_message = "Owner needs to be a valid email address." 103 | } 104 | } 105 | 106 | variable "aws_account_id" { 107 | type = string 108 | description = "Needed for Guards to ensure code is being deployed to the correct account" 109 | } 110 | 111 | variable "tags" { 112 | type = map(string) 113 | description = "Tags added to all resources, this will be added to the list of mandatory tags" 114 | } 115 | 116 | # _____ _ _ ___ __________________ ___ _____ _ _____ 117 | # | __ \ | | |/ _ \ | ___ \ _ \ ___ \/ _ \|_ _| | / ___| 118 | # | | \/ | | / /_\ \| |_/ / | | | |_/ / /_\ \ | | | | \ `--. 119 | # | | __| | | | _ || /| | | | /| _ | | | | | `--. \ 120 | # | |_\ \ |_| | | | || |\ \| |/ /| |\ \| | | |_| |_| |____/\__/ / 121 | # \____/\___/\_| |_/\_| \_|___/ \_| \_\_| |_/\___/\_____/\____/ 122 | # 123 | # These will raise errors when the conditions are not met 124 | 125 | data "aws_caller_identity" "current" { 126 | provider = aws.reference 127 | } 128 | 129 | 130 | resource "null_resource" "tf_guard_provider_account_match" { 131 | count = tonumber(data.aws_caller_identity.current.account_id == var.aws_account_id ? "1" : "fail") 132 | } 133 | 134 | # _ _ ___ ___ ________ _ _ _____ 135 | # | \ | | / _ \ | \/ |_ _| \ | | __ \ 136 | # | \| |/ /_\ \| . . | | | | \| | | \/ 137 | # | . ` || _ || |\/| | | | | . ` | | __ 138 | # | |\ || | | || | | |_| |_| |\ | |_\ \ 139 | # \_| \_/\_| |_/\_| |_/\___/\_| \_/\____/ 140 | # 141 | # 142 | 143 | locals { 144 | mandatory_tags = merge(var.tags, { 145 | "cat:application" = var.application_name.long 146 | "cat:client" = var.client_name.long 147 | "cat:purpose" = var.purpose 148 | "cat:owner" = var.owner 149 | "cat:repo" = var.code_repo 150 | "cat:nukeable" = var.nukeable 151 | 152 | "tf:account_id" = data.aws_caller_identity.current.account_id 153 | "tf:caller_arn" = data.aws_caller_identity.current.arn 154 | "tf:user_id" = data.aws_caller_identity.current.user_id 155 | 156 | "app:region" = var.region 157 | "app:namespace" = var.namespace 158 | "app:environment" = var.environment 159 | }) 160 | 161 | naming_prefix = join("-", [ 162 | var.client_name.short, 163 | var.application_name.short, 164 | var.environment, 165 | var.namespace 166 | ]) 167 | } -------------------------------------------------------------------------------- /data.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | // https://aws.amazon.com/blogs/compute/upcoming-changes-to-the-python-sdk-in-aws-lambda/ 3 | python_layers = { 4 | "ap-northeast-1" = "arn:aws:lambda:ap-northeast-1:249908578461:layer:AWSLambda-Python-AWS-SDK:4" 5 | "us-east-1" = "arn:aws:lambda:us-east-1:668099181075:layer:AWSLambda-Python-AWS-SDK:4" 6 | "ap-southeast-1" = "arn:aws:lambda:ap-southeast-1:468957933125:layer:AWSLambda-Python-AWS-SDK:4" 7 | "eu-west-1" = "arn:aws:lambda:eu-west-1:399891621064:layer:AWSLambda-Python-AWS-SDK:4" 8 | "us-west-1" = "arn:aws:lambda:us-west-1:325793726646:layer:AWSLambda-Python-AWS-SDK:4" 9 | "ap-east-1" = "arn:aws:lambda:ap-east-1:118857876118:layer:AWSLambda-Python-AWS-SDK:4" 10 | "ap-northeast-2" = "arn:aws:lambda:ap-northeast-2:296580773974:layer:AWSLambda-Python-AWS-SDK:4" 11 | "ap-northeast-3" = "arn:aws:lambda:ap-northeast-3:961244031340:layer:AWSLambda-Python-AWS-SDK:4" 12 | "ap-south-1" = "arn:aws:lambda:ap-south-1:631267018583:layer:AWSLambda-Python-AWS-SDK:4" 13 | "ap-southeast-2" = "arn:aws:lambda:ap-southeast-2:817496625479:layer:AWSLambda-Python-AWS-SDK:4" 14 | "ca-central-1" = "arn:aws:lambda:ca-central-1:778625758767:layer:AWSLambda-Python-AWS-SDK:4" 15 | "eu-central-1" = "arn:aws:lambda:eu-central-1:292169987271:layer:AWSLambda-Python-AWS-SDK:4" 16 | "eu-north-1" = "arn:aws:lambda:eu-north-1:642425348156:layer:AWSLambda-Python-AWS-SDK:4" 17 | "eu-west-2" = "arn:aws:lambda:eu-west-2:142628438157:layer:AWSLambda-Python-AWS-SDK:4" 18 | "eu-west-3" = "arn:aws:lambda:eu-west-3:959311844005:layer:AWSLambda-Python-AWS-SDK:4" 19 | "sa-east-1" = "arn:aws:lambda:sa-east-1:640010853179:layer:AWSLambda-Python-AWS-SDK:4" 20 | "us-east-2" = "arn:aws:lambda:us-east-2:259788987135:layer:AWSLambda-Python-AWS-SDK:4" 21 | "us-west-2" = "arn:aws:lambda:us-west-2:420165488524:layer:AWSLambda-Python-AWS-SDK:5" 22 | "cn-north-1" = "arn:aws-cn:lambda:cn-north-1:683298794825:layer:AWSLambda-Python-AWS-SDK:4" 23 | "cn-northwest-1" = "arn:aws-cn:lambda:cn-northwest-1:382066503313:layer:AWSLambda-Python-AWS-SDK:4" 24 | "us-gov-west" = "arn:aws-us-gov:lambda:us-gov-west-1:556739011827:layer:AWSLambda-Python-AWS-SDK:4" 25 | "us-gov-east" = "arn:aws-us-gov:lambda:us-gov-east-1:138526772879:layer:AWSLambda-Python-AWS-SDK:4" 26 | } 27 | } -------------------------------------------------------------------------------- /iam.tf: -------------------------------------------------------------------------------- 1 | resource "aws_iam_role" "lambda" { 2 | name = local.naming_prefix 3 | 4 | assume_role_policy = data.aws_iam_policy_document.lambda_role_trust.json 5 | } 6 | 7 | data "aws_iam_policy_document" "lambda_role_trust" { 8 | statement { 9 | sid = "LambdaTrust" 10 | 11 | actions = [ 12 | "sts:AssumeRole" 13 | ] 14 | 15 | principals { 16 | type = "Service" 17 | identifiers = ["lambda.amazonaws.com"] 18 | } 19 | } 20 | } 21 | 22 | resource "aws_iam_role_policy" "lambda_permissions" { 23 | name = local.naming_prefix 24 | role = aws_iam_role.lambda.id 25 | policy = data.aws_iam_policy_document.lambda_permissions.json 26 | } 27 | 28 | 29 | data "aws_iam_policy_document" "lambda_permissions" { 30 | statement { 31 | sid = "Logging" 32 | 33 | actions = [ 34 | "logs:CreateLogGroup", 35 | "logs:CreateLogStream", 36 | "logs:PutLogEvents" 37 | ] 38 | 39 | resources = [ 40 | "arn:aws:logs:*:*:*" 41 | ] 42 | } 43 | 44 | statement { 45 | sid = "S3Access" 46 | 47 | actions = [ 48 | "s3:Get*", 49 | "s3:List*", 50 | "s3:Describe*", 51 | ] 52 | 53 | resources = [ 54 | "${var.cloudtrail_bucket.arn}/", 55 | "${var.cloudtrail_bucket.arn}/*", 56 | ] 57 | } 58 | 59 | statement { 60 | sid = "SSMAccess" 61 | 62 | actions = [ 63 | "ssm:Get*", 64 | ] 65 | 66 | resources = [ 67 | aws_ssm_parameter.slack_webhook.arn 68 | ] 69 | } 70 | 71 | statement { 72 | sid = "SQSAccess" 73 | 74 | actions = [ 75 | "sqs:*", 76 | ] 77 | 78 | resources = [ 79 | aws_sqs_queue.bucket_notifications.arn 80 | ] 81 | } 82 | 83 | } -------------------------------------------------------------------------------- /lambda.tf: -------------------------------------------------------------------------------- 1 | resource "aws_lambda_function" "func" { 2 | 3 | function_name = local.naming_prefix 4 | role = aws_iam_role.lambda.arn 5 | 6 | handler = "main.handler" 7 | runtime = "python3.8" 8 | 9 | filename = data.archive_file.func.output_path 10 | source_code_hash = filebase64sha256(data.archive_file.func.output_path) 11 | 12 | timeout = var.event_processing_timeout 13 | memory_size = 128 14 | 15 | layers = [local.python_layers[var.region]] 16 | 17 | environment { 18 | variables = { 19 | WEBHOOK_PARAMETER = aws_ssm_parameter.slack_webhook.name 20 | EXCLUDED_ACCOUNTS = jsonencode(var.excluded_accounts) 21 | INCLUDED_ACCOUNTS = jsonencode(var.included_accounts) 22 | } 23 | } 24 | } 25 | 26 | data "archive_file" "func" { 27 | type = "zip" 28 | source_dir = "${path.module}/lambda" 29 | output_file_mode = "0666" 30 | output_path = "${path.module}/lambda.zip" 31 | } 32 | 33 | resource "aws_lambda_event_source_mapping" "bucket_notifications" { 34 | event_source_arn = aws_sqs_queue.bucket_notifications.arn 35 | function_name = aws_lambda_function.func.arn 36 | } 37 | 38 | resource "aws_ssm_parameter" "slack_webhook" { 39 | 40 | name = "/${local.naming_prefix}/slack-webhook" 41 | description = "Slack Incomming Webhook. https://api.slack.com/messaging/webhooks" 42 | 43 | type = "SecureString" 44 | value = "REPLACE_ME" 45 | 46 | lifecycle { 47 | ignore_changes = [ 48 | value, 49 | ] 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /lambda/main.py: -------------------------------------------------------------------------------- 1 | import json 2 | import urllib.parse 3 | import urllib.request 4 | import boto3 5 | import io 6 | import gzip 7 | import re 8 | import os 9 | from botocore.vendored import requests 10 | 11 | s3 = boto3.client('s3') 12 | ssm = boto3.client('ssm') 13 | 14 | USER_AGENTS = { 15 | "console.amazonaws.com", 16 | "Coral/Jakarta", 17 | "Coral/Netty4" 18 | } 19 | USER_AGENTS_RE = [ 20 | "signin.amazonaws.com(.*)", 21 | "^S3Console", 22 | "^\[S3Console", 23 | "^Mozilla/", 24 | "^console(.*)amazonaws.com(.*)", 25 | "^aws-internal(.*)AWSLambdaConsole(.*)", 26 | ] 27 | IGNORED_EVENTS = { 28 | "DownloadDBLogFilePortion", 29 | "TestScheduleExpression", 30 | "TestEventPattern", 31 | "LookupEvents", 32 | "listDnssec", 33 | "Decrypt", 34 | "REST.GET.OBJECT_LOCK_CONFIGURATION", 35 | "ConsoleLogin" 36 | } 37 | IGNORED_SCOPED_EVENTS = [ 38 | "cognito-idp.amazonaws.com:InitiateAuth", 39 | "cognito-idp.amazonaws.com:RespondToAuthChallenge", 40 | 41 | "sso.amazonaws.com:Federate", 42 | "sso.amazonaws.com:Authenticate", 43 | "sso.amazonaws.com:Logout", 44 | "sso.amazonaws.com:SearchUsers", 45 | "sso.amazonaws.com:SearchGroups", 46 | 47 | "signin.amazonaws.com:UserAuthentication", 48 | "signin.amazonaws.com:SwitchRole", 49 | "signin.amazonaws.com:RenewRole", 50 | "signin.amazonaws.com:ExternalIdPDirectoryLogin", 51 | 52 | "logs.amazonaws.com:StartQuery", 53 | 54 | "iam.amazonaws.com:SimulatePrincipalPolicy", 55 | "iam.amazonaws.com:GenerateServiceLastAccessedDetails", 56 | 57 | "glue.amazonaws.com:BatchGetJobs", 58 | "glue.amazonaws.com:BatchGetCrawlers", 59 | "glue.amazonaws.com:StartJobRun", 60 | "glue.amazonaws.com:StartCrawler", 61 | 62 | "athena.amazonaws.com:StartQueryExecution", 63 | 64 | "servicecatalog.amazonaws.com:SearchProductsAsAdmin", 65 | "servicecatalog.amazonaws.com:SearchProducts", 66 | "servicecatalog.amazonaws.com:SearchProvisionedProducts", 67 | "servicecatalog.amazonaws.com:TerminateProvisionedProduct", 68 | 69 | "cloudshell.amazonaws.com:CreateSession", 70 | "cloudshell.amazonaws.com:PutCredentials", 71 | "cloudshell.amazonaws.com:SendHeartBeat", 72 | "cloudshell.amazonaws.com:CreateEnvironment" 73 | ] 74 | READONLY_EVENTS_RE = [ 75 | "^Get", 76 | "^Describe", 77 | "^List", 78 | "^Head", 79 | ] 80 | 81 | WEBHOOK_PARAMETER = os.environ['WEBHOOK_PARAMETER'] 82 | EXCLUDED_ACCOUNTS = json.loads(os.environ['EXCLUDED_ACCOUNTS']) 83 | INCLUDED_ACCOUNTS = json.loads(os.environ['INCLUDED_ACCOUNTS']) 84 | 85 | WEBHOOK_URL = None 86 | 87 | def get_wekbhook() -> str: 88 | global WEBHOOK_URL 89 | if WEBHOOK_URL is None: 90 | response = ssm.get_parameter(Name=WEBHOOK_PARAMETER, WithDecryption=True) 91 | WEBHOOK_URL = response['Parameter']['Value'] 92 | 93 | return WEBHOOK_URL 94 | 95 | def send_slack_message(user, event, s3_bucket, s3_key, webhook) -> bool: 96 | slack_payload = { 97 | "blocks": [ 98 | { 99 | "type": "header", 100 | "text": { 101 | "type": "plain_text", 102 | "text": ":bell: ClickOps Alert :bell:", 103 | "emoji": True 104 | } 105 | }, 106 | { 107 | "type": "section", 108 | "text": { 109 | "type": "mrkdwn", 110 | "text": "Someone is practicing ClickOps in your AWS Account!" 111 | } 112 | }, 113 | { 114 | "type": "section", 115 | "fields": [ 116 | { 117 | "type": "mrkdwn", 118 | "text": f"*Account Id*\n{event['recipientAccountId']}" 119 | }, 120 | { 121 | "type": "mrkdwn", 122 | "text": f"*Region*\n{event['awsRegion']}" 123 | } 124 | ] 125 | }, 126 | { 127 | "type": "section", 128 | "fields": [ 129 | { 130 | "type": "mrkdwn", 131 | "text": f"*IAM Action*\n{event['eventSource'].split('.')[0]}:{event['eventName']}" 132 | }, 133 | { 134 | "type": "mrkdwn", 135 | "text": f"*Principle*\n{user}" 136 | } 137 | ] 138 | }, 139 | { 140 | "type": "section", 141 | "fields": [ 142 | { 143 | "type": "mrkdwn", 144 | "text": f"*Cloudtrail Bucket*\n{s3_bucket}" 145 | }, 146 | { 147 | "type": "mrkdwn", 148 | "text": f"*Key*\n{s3_key}" 149 | } 150 | ] 151 | }, 152 | { 153 | "type": "divider" 154 | }, 155 | { 156 | "type": "section", 157 | "text": { 158 | "type": "mrkdwn", 159 | "text": f"*Event*\n```{json.dumps(event, indent=2)}```" 160 | } 161 | }, 162 | ] 163 | } 164 | 165 | response = requests.post(webhook, json=slack_payload) 166 | if response.status_code != 200: 167 | return False 168 | return True 169 | 170 | def valid_account(key) -> bool: 171 | if len(EXCLUDED_ACCOUNTS) == 0 and len(INCLUDED_ACCOUNTS) == 0: 172 | return True 173 | 174 | if any(acc for acc in EXCLUDED_ACCOUNTS if acc in key ): 175 | print(f'{key} in {json.dumps(EXCLUDED_ACCOUNTS)}') 176 | return False 177 | 178 | if len(INCLUDED_ACCOUNTS) == 0: 179 | return True 180 | 181 | if any(acc for acc in INCLUDED_ACCOUNTS if acc in key): 182 | return True 183 | 184 | print(f'{key} not in {json.dumps(INCLUDED_ACCOUNTS)}') 185 | return False 186 | 187 | def check_regex(expr, txt) -> bool: 188 | match = re.search(expr, txt) 189 | return match is not None 190 | 191 | def match_user_agent(txt) -> bool: 192 | if txt in USER_AGENTS: 193 | return True 194 | 195 | for expresion in USER_AGENTS_RE: 196 | if check_regex(expresion, txt): 197 | return True 198 | 199 | return False 200 | 201 | def match_readonly_event(event) ->bool: 202 | if 'readOnly' in event: 203 | if event['readOnly'] == 'true' or event['readOnly'] == True: 204 | return True 205 | else: 206 | return False 207 | else: 208 | return False 209 | 210 | def match_readonly_event_name(txt) -> bool: 211 | for expression in READONLY_EVENTS_RE: 212 | if check_regex(expression, txt): 213 | return True 214 | 215 | return False 216 | 217 | def match_ignored_events(event_name) -> bool: 218 | return event_name in IGNORED_EVENTS 219 | 220 | def match_ignored_scoped_events(event_name, event_source) -> bool: 221 | return f'{event_source}:{event_name}' in IGNORED_SCOPED_EVENTS 222 | 223 | def filter_user_events(event) -> bool: 224 | is_match = match_user_agent(event['userAgent']) 225 | is_readonly_event = match_readonly_event(event) 226 | is_readonly_action = match_readonly_event_name(event['eventName']) 227 | is_ignored_event = match_ignored_events(event['eventName']) 228 | is_ignored_scoped_event = match_ignored_scoped_events(event['eventName'], event['eventSource'], ) 229 | is_in_event = 'invokedBy' in event['userIdentity'] and event['userIdentity']['invokedBy'] == 'AWS Internal' 230 | 231 | info = { 232 | 'is_match': is_match, 233 | 'is_readonly_event': is_readonly_event, 234 | 'is_readonly_action': is_readonly_action, 235 | 'is_ignored_event': is_ignored_event, 236 | 'is_ignored_scoped_event': is_ignored_scoped_event, 237 | 'is_in_event': is_in_event 238 | } 239 | 240 | print("--- filter_user_events output ---") 241 | print(json.dumps(event)) 242 | print(json.dumps(info)) 243 | 244 | status = is_match and not is_readonly_event and not is_readonly_action and not is_ignored_event and not is_in_event and not is_ignored_scoped_event 245 | 246 | return status 247 | 248 | 249 | def get_user_email(principal_id) -> str: 250 | words = principal_id.split(':') 251 | if len(words) > 1: 252 | return words[1] 253 | return principal_id 254 | 255 | 256 | """ 257 | This functions processes CloudTrail logs from S3, filters events from the AWS Console, and publishes to SNS 258 | :param event: List of S3 Events 259 | :param context: AWS Lambda Context Object 260 | :return: None 261 | """ 262 | def handler(event, context) -> None: 263 | 264 | # print("--- SQS EVENT ---") 265 | # print(json.dumps(event)) 266 | 267 | webhook_url = get_wekbhook() 268 | 269 | for sqs_record in event['Records']: 270 | s3_events = json.loads(sqs_record['body']) 271 | 272 | # print("--- Bucket EVENT ---") 273 | # print(json.dumps(s3_events)) 274 | 275 | records = s3_events.get("Records", []) 276 | 277 | for record in records: 278 | 279 | # Get the object from the event and show its content type 280 | bucket = record['s3']['bucket']['name'] 281 | key = urllib.parse.unquote_plus(record['s3']['object']['key'], encoding='utf-8') 282 | 283 | key_elements = key.split("/") 284 | if "CloudTrail" not in key_elements: 285 | continue 286 | 287 | # print("--- CloudTrail Event ---") 288 | # print(json.dumps(record)) 289 | 290 | if not valid_account(key): 291 | continue 292 | 293 | try: 294 | response = s3.get_object(Bucket=bucket, Key=key) 295 | content = response['Body'].read() 296 | 297 | with gzip.GzipFile(fileobj=io.BytesIO(content), mode='rb') as fh: 298 | event_json = json.load(fh) 299 | output_dict = [record for record in event_json['Records'] if filter_user_events(record)] 300 | for item in output_dict: 301 | user = get_user_email(item['userIdentity']['principalId']) 302 | if not send_slack_message(user, item, s3_bucket=bucket, s3_key=key, webhook=webhook_url): 303 | print("[ERROR] Slack Message not sent") 304 | print(json.dumps(item)) 305 | # return response['ContentType'] 306 | except Exception as e: 307 | print(e) 308 | message = f""" 309 | Error getting object {key} from bucket {bucket}. 310 | Make sure they exist and your bucket is in the same region as this function. 311 | """ 312 | print(message) 313 | raise e 314 | 315 | return "Completed" 316 | -------------------------------------------------------------------------------- /logging.tf: -------------------------------------------------------------------------------- 1 | resource "aws_cloudwatch_log_group" "func" { 2 | name = "/aws/lambda/${aws_lambda_function.func.function_name}" 3 | retention_in_days = var.log_retention_in_days 4 | } -------------------------------------------------------------------------------- /terraform.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_providers { 3 | aws = { 4 | source = "hashicorp/aws" 5 | version = "3.49.0" 6 | } 7 | } 8 | } 9 | 10 | provider "aws" { 11 | alias = "reference" 12 | region = var.region 13 | } 14 | 15 | provider "aws" { 16 | region = var.region 17 | 18 | default_tags { 19 | tags = local.mandatory_tags 20 | } 21 | } -------------------------------------------------------------------------------- /trigger.tf: -------------------------------------------------------------------------------- 1 | resource "aws_sqs_queue" "bucket_notifications" { 2 | name = local.naming_prefix 3 | visibility_timeout_seconds = var.event_processing_timeout + 5 4 | } 5 | 6 | resource "aws_sqs_queue_policy" "bucket_notifications" { 7 | queue_url = aws_sqs_queue.bucket_notifications.id 8 | 9 | policy = <