diff --git a/sam-notifier/.gitignore b/sam-notifier/.gitignore new file mode 100644 index 0000000..30ee366 --- /dev/null +++ b/sam-notifier/.gitignore @@ -0,0 +1,249 @@ + +# Created by https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### OSX ### +*.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### PyCharm ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff: +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/dictionaries + +# Sensitive or high-churn files: +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.xml +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml + +# Gradle: +.idea/**/gradle.xml +.idea/**/libraries + +# CMake +cmake-build-debug/ + +# Mongo Explorer plugin: +.idea/**/mongoSettings.xml + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +/out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Ruby plugin and RubyMine +/.rakeTasks + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +### PyCharm Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +.idea/sonarlint + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +.pytest_cache/ +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Translations +*.mo +*.pot + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule.* + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +ehthumbs.db +ehthumbs_vista.db + +# Folder config file +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# Build folder + +*/build/* + +# End of https://www.gitignore.io/api/osx,linux,python,windows,pycharm,visualstudiocode + +# AWS Serverless Application Model +.aws-sam +.aws-sam/* +samconfig.toml diff --git a/sam-notifier/README.md b/sam-notifier/README.md new file mode 100644 index 0000000..582411e --- /dev/null +++ b/sam-notifier/README.md @@ -0,0 +1,58 @@ +# SAM Notifier (sam-notifier) + +This project contains source code and supporting files for a serverless application that you can deploy with the SAM CLI. It includes the following files and folders: + +- functions - Code for the application's Lambda functions to respond to CIS AWS Foundations Benchmark CloudWatch Alarms. +- statemachines - Definition for the state machine that orchestrates the notification workflow. +- template.yaml - A template that defines the application's AWS resources. + +This application creates a AWS Step Functions workflow coupled with event-driven approach using Amazon EventBridge to respond to CIS AWS Foundations Benchmark CloudWatch Alarms provisioned via the [CIS-alarms-cfn.yml](https://github.com/rewindio/aws-security-hub-CIS-metrics/blob/main/CIS-alarms-cfn.yml) CloudFormation template. The event detail information is saved in a Amazon DynamoDB table, as well as supplied to a Jira Cloud issue. + +The application uses several AWS resources, including Step Functions state machines, Lambda functions and an EventBridge rule. These resources are defined in the `template.yaml` file in this project. You can update the template to add AWS resources through the same deployment process that updates your application code. + +## Deploy the sample application + +The Serverless Application Model Command Line Interface (SAM CLI) is an extension of the AWS CLI that adds functionality for building and testing Lambda applications. It uses Docker to run your functions in an Amazon Linux environment that matches Lambda. + +To use the SAM CLI, you need the following tools: + +* SAM CLI - [Install the SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html) +* [Python 3 installed](https://www.python.org/downloads/) +* Docker - [Install Docker community edition](https://hub.docker.com/search/?type=edition&offering=community) + +To build and deploy your application for the first time, run the following in your shell: + +```bash +sam build --use-container +sam deploy --guided +``` + +The first command will build the source of your application. The second command will package and deploy your application to AWS, with a series of prompts: + +* **Stack Name**: The name of the stack to deploy to CloudFormation. This should be unique to your account and region, and a good starting point would be something matching your project name. +* **AWS Region**: The AWS region you want to deploy your app to. +* **Parameter LogGroupName**: Name of the CloudWatch Logs log group used CloudTrail +* **Jira Url**: Jira REST API URL (ex. https://.atlassian.net/rest/api/2) +* **Jira Project Key**: Jira project key +* **Jira Auth Token**: Jira Basic Auth token [Atlassian Developer - Basic auth for REST APIs](https://developer.atlassian.com/cloud/jira/platform/basic-auth-for-rest-apis/) +* **Confirm changes before deploy**: If set to yes, any change sets will be shown to you before execution for manual review. If set to no, the AWS SAM CLI will automatically deploy application changes. +* **Allow SAM CLI IAM role creation**: Many AWS SAM templates, including this example, create AWS IAM roles required for the AWS Lambda function(s) included to access AWS services. By default, these are scoped down to minimum required permissions. To deploy an AWS CloudFormation stack which creates or modifies IAM roles, the `CAPABILITY_IAM` value for `capabilities` must be provided. If permission isn't provided through this prompt, to deploy this example you must explicitly pass `--capabilities CAPABILITY_IAM` to the `sam deploy` command. +* **Save arguments to samconfig.toml**: If set to yes, your choices will be saved to a configuration file inside the project, so that in the future you can just re-run `sam deploy` without parameters to deploy changes to your application. + +## Use the SAM CLI to build locally + +Build the Lambda functions in your application with the `sam build --use-container` command. + +```bash +sam-notifier$ sam build --use-container +``` + +The SAM CLI installs dependencies defined in `functions/*/requirements.txt`, creates a deployment package, and saves it in the `.aws-sam/build` folder. + +## Cleanup + +To delete the sample application that you created, use the AWS CLI. Assuming you used your project name for the stack name, you can run the following: + +```bash +aws cloudformation delete-stack --stack-name sam-notifier +``` diff --git a/sam-notifier/__init__.py b/sam-notifier/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sam-notifier/functions/__init__.py b/sam-notifier/functions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sam-notifier/functions/create-ticket/__init__.py b/sam-notifier/functions/create-ticket/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sam-notifier/functions/create-ticket/app.py b/sam-notifier/functions/create-ticket/app.py new file mode 100644 index 0000000..a649360 --- /dev/null +++ b/sam-notifier/functions/create-ticket/app.py @@ -0,0 +1,201 @@ +r""" +The ``create-ticket`` Lambda retrieves the Event details from the +DynamoDB table and creates a Jira issue. +""" + +import logging +import json +import requests + +import boto3 +from botocore.exceptions import ClientError + + +dynamodb = boto3.resource("dynamodb") + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + + +def get_secret(jira_auth_token_secret_arn): + session = boto3.session.Session() + client = session.client(service_name="secretsmanager") + + try: + get_secret_value_response = client.get_secret_value( + SecretId=jira_auth_token_secret_arn + ) + except ClientError as err: + logger.error("Error, unable to access Secret '%s'.", jira_auth_token_secret_arn) + logger.error(err) + + if "SecretString" in get_secret_value_response: + return get_secret_value_response["SecretString"] + + +def get_jira_auth_header(jira_auth_token_secret_arn): + headers = { + "Authorization": f"Basic {get_secret(jira_auth_token_secret_arn)}", + "Content-Type": "application/json", + } + return headers + + +def create_jira_issue(jira_url, headers, issue_data): + resp = requests.post( + f"{jira_url}/issue/", headers=headers, data=json.dumps(issue_data), timeout=60 + ) + logger.info("Jira Response: %s (%s)", resp, resp.url) + + # Raise AssertionError if Atlassian API returns status code 429 + assert resp.status_code != 429, "Atlassian API returned '429 Too Many Requests'." + + if ( + resp.status_code == 201 + or resp.status_code == requests.codes.ok # pylint: disable=E1101 + ): + ticket = resp.json() + logger.info("Successfully created ticket: %s", ticket.get("key")) + return ticket.get("key") + else: + error_msg = ( + "Atlassian API responded with (%s): %s", + resp.status_code, + resp.json(), + ) + logger.error(error_msg) + raise Exception(error_msg) + + +def get_ddb_event(event_id, table): + logger.info("Get EventID: %s, DynamoDB table: %s", event_id, table) + try: + response = table.get_item(Key={"EventId": event_id}) + except ClientError as err: + logger.error( + "Couldn't get event %s from table %s. Here's why: %s: %s", + event_id, + table.name, + err.response["Error"]["Code"], + err.response["Error"]["Message"], + ) + raise + else: + return response["Item"] + + +def update_ddb_event(event_id, ticket_id, table): + logger.info( + "Update EventID: %s, Ticket: %s, DynamoDB table: %s", event_id, ticket_id, table + ) + try: + response = table.update_item( + Key={"EventId": event_id}, + UpdateExpression="SET TicketId=:newTicketId", + ExpressionAttributeValues={":newTicketId": ticket_id}, + ReturnValues="UPDATED_NEW", + ) + except ClientError as err: + logger.error( + "Couldn't update event %s from table %s. Here's why: %s: %s", + ticket_id, + table.name, + err.response["Error"]["Code"], + err.response["Error"]["Message"], + ) + raise + else: + return response + + +def process_log_record(log_record, jira_project_key, alarm_name): + logger.info("LogRecord (%s): %s", type(log_record), log_record) + issue_data = { + "fields": { + "project": {"key": jira_project_key}, + "summary": f"{alarm_name} ({log_record.get('eventID')})", + "description": None, + "issuetype": {"name": "Task"}, + } + } + + formatted = f"Alarm Name: {alarm_name} \n" + formatted = f"Event ID: {log_record.get('eventID')} \n\n" + + event_details = "Event Details \n" + user_details = "User Details \n" + additional_details = "Additional Details \n" + response_elements = "Response Elements \n" + + for key, val in log_record.items(): + if key in ("eventID", "eventVersion"): + # Skipping these Event entries + continue + + if key in ( + "eventName", + "errorMessage", + "eventSource", + "eventTime", + "eventType", + "eventCategory", + ): + event_details += f"{key}: {val if val else 'NO_VALUE_SPECIFIED'} \n" + continue + + if key == "userIdentity": + for key, val in log_record.get("userIdentity").items(): + user_details += f"{key}: {val if val else 'NO_VALUE_SPECIFIED'} \n" + continue + + if key in ("sourceIPAddress", "userAgent", "recipientAccountId", "awsRegion"): + user_details += f"{key}: {val if val else 'NO_VALUE_SPECIFIED'} \n" + continue + + if key == "responseElements": + for key, val in log_record.get("responseElements").items(): + response_elements += f"{key}: {val if val else 'NO_VALUE_SPECIFIED'} \n" + continue + + additional_details += f"{key}: {val if val else 'NO_VALUE_SPECIFIED'} \n" + + formatted += f"{event_details} \n" + formatted += f"{user_details} \n" + formatted += f"{additional_details} \n" + formatted += f"{response_elements} \n" + + issue_data["fields"]["description"] = formatted + return issue_data + + +def lambda_handler(event, context): + logger.debug("Context: %s", context) + + table = dynamodb.Table(event.get("DDBAuditTableName")) + + jira_url = event.get("JiraUrl") + jira_project_key = event.get("JiraProjectKey") + jira_auth_token_secret_arn = event.get("JiraAuthTokenSecretArn") + + logger.info("Event: %s", event) + + alarm_name = event.get("alarmName", "AWS CIS Benchmark Alarm") + ticket_id = None + + if event.get("EventId"): + event_record = get_ddb_event(event.get("EventId"), table) + logger.info("Event record: %s", event_record) + + if event_record.get("LogRecord"): + jira_issue_data = process_log_record( + event_record.get("LogRecord"), jira_project_key, alarm_name + ) + logger.info("Jira Issue Data payload: %s", jira_issue_data) + ticket_id = create_jira_issue( + jira_url, + get_jira_auth_header(jira_auth_token_secret_arn), + jira_issue_data, + ) + update_ddb_event(event.get("EventId"), ticket_id, table) + + return {"TicketID": ticket_id} diff --git a/sam-notifier/functions/create-ticket/requirements.txt b/sam-notifier/functions/create-ticket/requirements.txt new file mode 100644 index 0000000..f229360 --- /dev/null +++ b/sam-notifier/functions/create-ticket/requirements.txt @@ -0,0 +1 @@ +requests diff --git a/sam-notifier/functions/epoch/__init__.py b/sam-notifier/functions/epoch/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sam-notifier/functions/epoch/app.py b/sam-notifier/functions/epoch/app.py new file mode 100644 index 0000000..23ec1df --- /dev/null +++ b/sam-notifier/functions/epoch/app.py @@ -0,0 +1,38 @@ +r""" +The ``epoch`` Lambda converts the Event date and time to epoch time +as per the Request Parameter for the CloudWatch Log StartQuery call. +""" + +import logging + +import datetime +from datetime import timedelta + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + + +def check_time(event): + if event.get("time"): + return datetime.datetime.strptime(event.get("time"), "%Y-%m-%dT%H:%M:%SZ") + return datetime.datetime.now() + + +def lambda_handler(event, context): + logger.debug("Context: %s", context) + + alarm_time = check_time(event) + + logger.info("Event time: %s", alarm_time) + + start_time = alarm_time - timedelta(minutes=10) + start_timestamp = round(start_time.timestamp()) + end_time = datetime.datetime.now() + end_timestamp = round(end_time.timestamp()) + + logger.info("Start time (%s), end time (%s).", start_time, end_time) + logger.info( + "Start timestamp (%s), end timetamps (%s).", start_timestamp, end_timestamp + ) + + return {"start": start_timestamp, "end": end_timestamp} diff --git a/sam-notifier/functions/epoch/requirements.txt b/sam-notifier/functions/epoch/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/sam-notifier/functions/log-processor/__init__.py b/sam-notifier/functions/log-processor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sam-notifier/functions/log-processor/app.py b/sam-notifier/functions/log-processor/app.py new file mode 100644 index 0000000..6f36e0f --- /dev/null +++ b/sam-notifier/functions/log-processor/app.py @@ -0,0 +1,53 @@ +r""" +The ``log-processor`` Lambda processes the Events returned by ``log-query`` +Lambda. This is required as multiple Log entries can be returned. +""" + +import logging +import json +import boto3 + + +dynamodb = boto3.resource("dynamodb") + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + + +def event_exists(event_id, table): + response = table.get_item(Key={"EventId": event_id}) + logger.info("AuditTable query (%s): %s", event_id, response) + return True if response.get("Item") else False + + +def process_log_entry(event, table, alarm_type): + logger.info("Log entry (%s): %s", type(event), event) + if not event_exists(event.get("eventID"), table): + logger.info("Storing EventID (%s) in AuditTable.", event.get("eventID")) + table.put_item( + Item={ + "EventId": event.get("eventID"), + "EventTime": event.get("eventTime"), + "TicketId": None, + "LogRecord": event, + "AlarmType": alarm_type, + } + ) + return event.get("eventID") + logger.info("Event (%s) has already been processed.", event.get("eventID")) + + +def lambda_handler(event, context): + logger.debug("Context: %s", context) + + table = dynamodb.Table(event.get("DDBAuditTableName")) + new_events = list() + + for log_event in event.get("logs").get("logs"): + log_event = json.loads(log_event) + logger.info("Processing log event: %s", log_event.get("eventID")) + result = process_log_entry(log_event, table, event.get("alarmName")) + if result: + new_events.append(result) + + return {"events": new_events} diff --git a/sam-notifier/functions/log-processor/requirements.txt b/sam-notifier/functions/log-processor/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/sam-notifier/functions/log-query/__init__.py b/sam-notifier/functions/log-query/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/sam-notifier/functions/log-query/app.py b/sam-notifier/functions/log-query/app.py new file mode 100644 index 0000000..0bdb90c --- /dev/null +++ b/sam-notifier/functions/log-query/app.py @@ -0,0 +1,71 @@ +r""" +The ``log-query`` Lambda executes CloudWatch Log query and returns +log Events assocaited with the CloudWatch Alarm. +""" + +import time +import logging +import boto3 + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +client = boto3.client("logs") + + +def get_query_results(query_id): + response = client.get_query_results(queryId=query_id) + + logger.info("Get Query Results: %s", response) + + # Wait for the Query to complete + while response.get("status") in ("Running", "Scheduled"): + time.sleep(5) + response = client.get_query_results(queryId=query_id) + if response.get("status") == "Complete": + logger.info("Query results: %s", response) + logger.info("Query statistics: %s", response.get("statistics")) + else: + logger.error("CloudWatch Query failed. %s", response.get("status")) + return response + + +def process_query_results(results): + log_entries = list() + logger.info("Process Query Results: %s", results) + for result in results.get("results"): + for entry in result: + if entry.get("field") == "@message": + logger.info(entry.get("value")) + log_entries.append(entry.get("value")) + return log_entries + + +def lambda_handler(event, context): + logger.debug("Context: %s", context) + + log_group_name = event.get("CloudWatchLogsLogGroupName") + + logger.info("StartTime: %s", event.get("epoch").get("start")) + logger.info("EndTime: %s", event.get("epoch").get("end")) + logger.info("QueryString: %s", event.get("query").get("string")) + logger.info("LogGroupName: %s", log_group_name) + + response = client.start_query( + logGroupName=log_group_name, + startTime=event.get("epoch").get("start"), + endTime=event.get("epoch").get("end"), + queryString=event.get("query").get("string"), + limit=10, + ) + + logger.info("Response: %s", response) + logger.info("CloudWatch Logs QueryId: %s", response.get("queryId")) + + results = get_query_results(response.get("queryId")) + logs = process_query_results(results) + + # Raise AssertionError if CloudWatch Logs query returned zero logs + assert len(logs) >= 1, "CloudWatch Logs query did not return any entries." + + return {"logs": logs} diff --git a/sam-notifier/functions/log-query/requirements.txt b/sam-notifier/functions/log-query/requirements.txt new file mode 100644 index 0000000..e69de29 diff --git a/sam-notifier/statemachine/notifier.asl.json b/sam-notifier/statemachine/notifier.asl.json new file mode 100644 index 0000000..6865612 --- /dev/null +++ b/sam-notifier/statemachine/notifier.asl.json @@ -0,0 +1,367 @@ +{ + "Comment": "A state machine for CIS Alarm notifer.", + "StartAt": "Validate Choice", + "States": { + "Validate Choice": { + "Type": "Choice", + "Choices": [ + { + "And": [ + { + "Variable": "$.detail.alarmName", + "IsPresent": true + } + ], + "Next": "EpochFunction", + "Comment": "Alarm Name Found" + } + ], + "Default": "Improper Input Fail" + }, + "EpochFunction": { + "Type": "Task", + "Resource": "${EpochFunctionArn}", + "InputPath": "$", + "ResultPath": "$.epoch", + "Retry": [ + { + "ErrorEquals": [ + "States.TaskFailed" + ], + "IntervalSeconds": 10, + "MaxAttempts": 1, + "BackoffRate": 1 + } + ], + "Next": "Event Handler Choice" + }, + "Event Handler Choice": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.detail.alarmName", + "StringMatches": "CIS-Unauthorized Activity Attempt", + "Next": "CIS-Unauthorized Activity Attempt", + "Comment": "CIS-Unauthorized Activity Attempt Alarm" + }, + { + "Variable": "$.detail.alarmName", + "StringMatches": "CIS-Console Signin Without MFA", + "Next": "CIS-Console Signin Without MFA", + "Comment": "CIS-Console Signin Without MFA Alarm" + }, + { + "Variable": "$.detail.alarmName", + "StringMatches": "CIS-Root Activity", + "Next": "CIS-Root Activity", + "Comment": "CIS-Root Activity Alarm" + }, + { + "Variable": "$.detail.alarmName", + "StringMatches": "CIS-IAM Policy Changes", + "Next": "CIS-IAM Policy Changes", + "Comment": "CIS-IAM Policy Changes Alarm" + }, + { + "Variable": "$.detail.alarmName", + "StringMatches": "CIS-Cloudtrail Config Changes", + "Next": "CIS-Cloudtrail Config Changes", + "Comment": "CIS-Cloudtrail Config Changes Alarm" + }, + { + "Variable": "$.detail.alarmName", + "StringMatches": "CIS-Console Login Failures", + "Next": "CIS-Console Login Failures", + "Comment": "CIS-Console Login Failures Alarm" + }, + { + "Variable": "$.detail.alarmName", + "StringMatches": "CIS-KMS Key Disabled or Scheduled for Deletion", + "Next": "CIS-KMS Key Disabled or Scheduled for Deletion", + "Comment": "CIS-KMS Key Disabled or Scheduled for Deletion Alarm" + }, + { + "Variable": "$.detail.alarmName", + "StringMatches": "CIS-S3 Bucket Policy Changed", + "Next": "CIS-S3 Bucket Policy Changed", + "Comment": "CIS-S3 Bucket Policy Changed Alarm" + }, + { + "Variable": "$.detail.alarmName", + "StringMatches": "CIS-AWS Config Configuration has changed", + "Next": "CIS-AWS Config Configuration has changed", + "Comment": "CIS-AWS Config Configuration has changed Alarm" + }, + { + "Variable": "$.detail.alarmName", + "StringMatches": "CIS-Security Groups Have Changed", + "Next": "CIS-Security Groups Have Changed", + "Comment": "CIS-Security Groups Have Changed Alarm" + }, + { + "Variable": "$.detail.alarmName", + "StringMatches": "CIS-NACLs Have Changed", + "Next": "CIS-NACLs Have Changed", + "Comment": "CIS-NACLs Have Changed Alarm" + }, + { + "Variable": "$.detail.alarmName", + "StringMatches": "CIS-Network Gateways Have Changed", + "Next": "CIS-Network Gateways Have Changed", + "Comment": "CIS-Network Gateways Have Changed Alarm" + }, + { + "Variable": "$.detail.alarmName", + "StringMatches": "CIS-Route Tables Have Changed", + "Next": "CIS-Route Tables Have Changed", + "Comment": "CIS-Route Tables Have Changed Alarm" + }, + { + "Variable": "$.detail.alarmName", + "StringMatches": "CIS-VPC Has Changed", + "Next": "CIS-VPC Has Changed", + "Comment": "CIS-VPC Has Changed Alarm" + } + ] + }, + "CIS-Unauthorized Activity Attempt": { + "Type": "Pass", + "Next": "LogQueryFunction", + "ResultPath": "$.query", + "Result": { + "string": "fields @timestamp, @message | sort @timestamp desc | filter errorCode LIKE 'UnauthorizedOperation' or errorCode LIKE 'AccessDenied'", + "severity": "LOW" + } + }, + "CIS-Console Signin Without MFA": { + "Type": "Pass", + "Next": "LogQueryFunction", + "ResultPath": "$.query", + "Result": { + "string": "fields @timestamp, @message | sort @timestamp desc | filter eventName == 'ConsoleLogin' and responseElements.ConsoleLogin == 'Success' and additionalEventData.MFAUsed != 'Yes'", + "severity": "HIGH" + } + }, + "CIS-Root Activity": { + "Type": "Pass", + "Next": "LogQueryFunction", + "ResultPath": "$.query", + "Result": { + "string": "fields @timestamp, @message | sort @timestamp desc | filter userIdentity.type == 'Root' and eventType != 'AwsServiceEvent'", + "severity": "HIGH" + } + }, + "CIS-IAM Policy Changes": { + "Type": "Pass", + "Next": "LogQueryFunction", + "ResultPath": "$.query", + "Result": { + "string": "fields @timestamp, @message | sort @timestamp desc | filter eventName in ['AttachGroupPolicy', 'AttachRolePolicy', 'AttachUserPolicy', 'CreatePolicy', 'CreatePolicyVersion', 'DeleteGroupPolicy', 'DeletePolicy', 'DeletePolicyVersion', 'DeleteRolePolicy', 'DeleteUserPolicy', 'DetachGroupPolicy', 'DetachRolePolicy', 'DetachUserPolicy', 'PutGroupPolicy', 'PutRolePolicy', 'PutUserPolicy']", + "severity": "LOW" + } + }, + "CIS-Cloudtrail Config Changes": { + "Type": "Pass", + "Next": "LogQueryFunction", + "ResultPath": "$.query", + "Result": { + "string": "fields @timestamp, @message | sort @timestamp desc | filter eventName in ['CreateTrail', 'DeleteTrail', 'StartLogging', 'StopLogging', 'UpdateTrail']", + "severity": "HIGH" + } + }, + "CIS-Console Login Failures": { + "Type": "Pass", + "Next": "LogQueryFunction", + "ResultPath": "$.query", + "Result": { + "string": "fields @timestamp, @message | sort @timestamp desc | filter eventName == 'ConsoleLogin' and errorMessage == 'Failed authentication'", + "severity": "HIGH" + } + }, + "CIS-KMS Key Disabled or Scheduled for Deletion": { + "Type": "Pass", + "Next": "LogQueryFunction", + "ResultPath": "$.query", + "Result": { + "string": "fields @timestamp, @message | sort @timestamp desc | filter eventSource == 'kms.amazonaws.com' and eventName in ['DisableKey', 'ScheduleKeyDeletion']", + "severity": "LOW" + } + }, + "CIS-S3 Bucket Policy Changed": { + "Type": "Pass", + "Next": "LogQueryFunction", + "ResultPath": "$.query", + "Result": { + "string": "fields @timestamp, @message | sort @timestamp desc | filter eventSource = 's3.amazonaws.com' and eventName in ['DeleteBucketCors', 'DeleteBucketLifecycle', 'DeleteBucketPolicy', 'DeleteBucketReplication', 'PutBucketAcl', 'PutBucketCors', 'PutBucketLifecycle', 'PutBucketPolicy', 'PutBucketReplication']", + "severity": "LOW" + } + }, + "CIS-AWS Config Configuration has changed": { + "Type": "Pass", + "Next": "LogQueryFunction", + "ResultPath": "$.query", + "Result": { + "string": "fields @timestamp, @message | sort @timestamp desc | filter eventSource = 'config.amazonaws.com' and eventName in ['DeleteDeliveryChannel', 'StopConfigurationRecorder', 'PutConfigurationRecorder', 'PutDeliveryChannel']", + "severity": "LOW" + } + }, + "CIS-Security Groups Have Changed": { + "Type": "Pass", + "Next": "LogQueryFunction", + "ResultPath": "$.query", + "Result": { + "string": "filter eventName in ['AuthorizeSecurityGroupIngress', 'AuthorizeSecurityGroupEgress', 'CreateSecurityGroup', 'DeleteSecurityGroup', 'RevokeSecurityGroupIngress', 'RevokeSecurityGroupEgress']", + "severity": "LOW" + } + }, + "CIS-NACLs Have Changed": { + "Type": "Pass", + "Next": "LogQueryFunction", + "ResultPath": "$.query", + "Result": { + "string": "fields @timestamp, @message | sort @timestamp desc | filter eventName in ['CreateNetworkAcl', 'CreateNetworkAclEntry', 'DeleteNetworkAcl', 'DeleteNetworkAclEntry', 'ReplaceNetworkAclEntry', 'ReplaceNetworkAclAssociation']", + "severity": "LOW" + } + }, + "CIS-Network Gateways Have Changed": { + "Type": "Pass", + "Next": "LogQueryFunction", + "ResultPath": "$.query", + "Result": { + "string": "fields @timestamp, @message | sort @timestamp desc | filter eventName in ['AttachInternetGateway', 'CreateCustomerGateway', 'CreateInternetGateway', 'DeleteCustomerGateway', 'DeleteInternetGateway', 'DetachInternetGateway']", + "severity": "LOW" + } + }, + "CIS-Route Tables Have Changed": { + "Type": "Pass", + "Next": "LogQueryFunction", + "ResultPath": "$.query", + "Result": { + "string": "fields @timestamp, @message | sort @timestamp desc | filter eventName in ['CreateRoute', 'CreateRouteTable', 'DeleteRoute', 'DeleteRouteTable', 'DisassociateRouteTable', 'ReplaceRoute', 'ReplaceRouteTableAssociation']", + "severity": "LOW" + } + }, + "CIS-VPC Has Changed": { + "Type": "Pass", + "Next": "LogQueryFunction", + "ResultPath": "$.query", + "Result": { + "string": "fields @timestamp, @message | sort @timestamp desc | filter eventName in ['AcceptVpcPeeringConnection', 'AttachClassicLinkVpc', 'CreateVpc', 'CreateVpcPeeringConnection', 'DeleteVpc', 'DeleteVpcPeeringConnection', 'DetachClassicLinkVpc', 'DisableVpcClassicLink', 'EnableVpcClassicLink', 'ModifyVpcAttribute', 'RejectVpcPeeringConnection']", + "severity": "LOW" + } + }, + "LogQueryFunction": { + "Type": "Task", + "Resource": "${LogQueryFunctionArn}", + "InputPath": "$", + "ResultPath": "$.logs", + "Parameters": { + "CloudWatchLogsLogGroupName": "${LogGroupName}", + "alarmName.$": "$.detail.alarmName", + "epoch.$": "$.epoch", + "query.$": "$.query" + }, + "Retry": [ + { + "ErrorEquals": [ + "AssertionError" + ], + "IntervalSeconds": 300, + "MaxAttempts": 2 + }, + { + "ErrorEquals": [ + "States.TaskFailed" + ], + "IntervalSeconds": 300, + "MaxAttempts": 1 + } + ], + "Next": "LogProcessorFunction" + }, + "LogProcessorFunction": { + "Type": "Task", + "Resource": "${LogProcessorFunctionArn}", + "InputPath": "$", + "ResultPath": "$.events", + "Parameters": { + "DDBAuditTableName": "${DDBAuditTableName}", + "alarmName.$": "$.detail.alarmName", + "epoch.$": "$.epoch", + "query.$": "$.query", + "logs.$": "$.logs" + }, + "Retry": [ + { + "ErrorEquals": [ + "States.TaskFailed" + ], + "IntervalSeconds": 300, + "MaxAttempts": 1, + "BackoffRate": 1 + } + ], + "Next": "SeverityChoice" + }, + "SeverityChoice": { + "Type": "Choice", + "Choices": [ + { + "Variable": "$.query.severity", + "StringMatches": "LOW", + "Next": "Severity is LOW" + } + ], + "Default": "EventsMap" + }, + "EventsMap": { + "Type": "Map", + "InputPath": "$", + "ItemsPath": "$.events.events", + "Parameters": { + "DDBAuditTableName": "${DDBAuditTableName}", + "JiraUrl": "${JiraUrl}", + "JiraProjectKey": "${JiraProjectKey}", + "JiraAuthTokenSecretArn": "${JiraAuthTokenSecretArn}", + "alarmName.$": "$.detail.alarmName", + "EventId.$": "$$.Map.Item.Value" + }, + "Iterator": { + "StartAt": "CreateTicketFunction", + "States": { + "CreateTicketFunction": { + "Type": "Task", + "Resource": "${CreateTicketFunctionArn}", + "Retry": [ + { + "ErrorEquals": [ + "AssertionError" + ], + "IntervalSeconds": 300, + "MaxAttempts": 2 + }, + { + "ErrorEquals": [ + "States.TaskFailed" + ], + "IntervalSeconds": 300, + "MaxAttempts": 1 + } + ], + "End": true + } + } + }, + "End": true + }, + "Improper Input Fail": { + "Type": "Fail", + "Error": "Improper Input Fail", + "Cause": "Missing Alert Name" + }, + "Severity is LOW": { + "Type": "Succeed", + "Comment": "Skipping Jira issue creation, as the severity is below threshold." + } + } +} \ No newline at end of file diff --git a/sam-notifier/template.yaml b/sam-notifier/template.yaml new file mode 100644 index 0000000..dae0794 --- /dev/null +++ b/sam-notifier/template.yaml @@ -0,0 +1,174 @@ +AWSTemplateFormatVersion: "2010-09-09" +Transform: AWS::Serverless-2016-10-31 +Description: > + SAM Template to create state machine for CIS Alarm notifer + +Parameters: + StateMachineName: + Type: String + Description: The name of the state machine + LogGroupName: + Type: String + Description: Name of the CloudWatch Logs log group used CloudTrail + JiraUrl: + Type: String + Description: Jira REST API URL (ex. https://.atlassian.net/rest/api/2) + JiraProjectKey: + Type: String + Description: Jira project key + JiraAuthToken: + Type: String + Description: Jira Basic Auth token (https://developer.atlassian.com/cloud/jira/platform/basic-auth-for-rest-apis/) + NoEcho: true + +Resources: + StateMachine: + Type: AWS::Serverless::StateMachine + Properties: + Name: !Ref StateMachineName + DefinitionUri: statemachine/notifier.asl.json + DefinitionSubstitutions: + EpochFunctionArn: !GetAtt EpochFunction.Arn + LogQueryFunctionArn: !GetAtt LogQueryFunction.Arn + LogProcessorFunctionArn: !GetAtt LogProcessorFunction.Arn + CreateTicketFunctionArn: !GetAtt CreateTicketFunction.Arn + LogGroupName: !Ref LogGroupName + DDBAuditTableName: !Ref AuditTable + JiraUrl: !Ref JiraUrl + JiraProjectKey: !Ref JiraProjectKey + JiraAuthTokenSecretArn: !Ref JiraAuthTokenSecret + Events: + EBRule: + Type: EventBridgeRule + Properties: + Pattern: + source: + - aws.cloudwatch + detail: + state: + value: + - ALARM + alarmName: + - CIS-Unauthorized Activity Attempt + - CIS-Console Signin Without MFA + - CIS-Root Activity + - CIS-IAM Policy Changes + - CIS-Cloudtrail Config Changes + - CIS-Console Login Failures + - CIS-KMS Key Disabled or Scheduled for Deletion + - CIS-S3 Bucket Policy Changed + - CIS-AWS Config Configuration has changed + - CIS-Security Groups Have Changed + - CIS-NACLs Have Changed + - CIS-Network Gateways Have Changed + - CIS-Route Tables Have Changed + - CIS-VPC Has Changed + Policies: + - LambdaInvokePolicy: + FunctionName: !Ref EpochFunction + - LambdaInvokePolicy: + FunctionName: !Ref LogQueryFunction + - LambdaInvokePolicy: + FunctionName: !Ref LogProcessorFunction + - LambdaInvokePolicy: + FunctionName: !Ref CreateTicketFunction + + EpochFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: functions/epoch/ + Handler: app.lambda_handler + Runtime: python3.9 + Timeout: 5 + Architectures: + - x86_64 + + LogQueryFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: functions/log-query/ + Handler: app.lambda_handler + Runtime: python3.9 + Timeout: 300 + Architectures: + - x86_64 + + LogProcessorFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: functions/log-processor/ + Handler: app.lambda_handler + Runtime: python3.9 + Timeout: 300 + Architectures: + - x86_64 + Policies: + - DynamoDBWritePolicy: + TableName: !Ref AuditTable + - DynamoDBReadPolicy: + TableName: !Ref AuditTable + + CreateTicketFunction: + Type: AWS::Serverless::Function + Properties: + CodeUri: functions/create-ticket/ + Handler: app.lambda_handler + Runtime: python3.9 + Timeout: 300 + Architectures: + - x86_64 + Policies: + - DynamoDBWritePolicy: + TableName: !Ref AuditTable + - DynamoDBReadPolicy: + TableName: !Ref AuditTable + - AWSSecretsManagerGetSecretValuePolicy: + SecretArn: !Ref JiraAuthTokenSecret + + AuditTable: + Type: AWS::Serverless::SimpleTable + Properties: + PrimaryKey: + Name: EventId + Type: String + ProvisionedThroughput: + ReadCapacityUnits: 1 + WriteCapacityUnits: 1 + + JiraAuthTokenSecret: + Type: AWS::SecretsManager::Secret + Properties: + Description: String + SecretString: !Ref JiraAuthToken + + CloudWatchLogsPolicy: + Type: 'AWS::IAM::Policy' + Properties: + PolicyName: CloudWatchLogsPolicyForSamNotifier + PolicyDocument: + Version: "2012-10-17" + Statement: + - Sid: CloudWatchLogsPolicy + Effect: Allow + Action: + - 'logs:Describe*' + - 'logs:Get*' + - 'logs:List*' + - 'logs:StartQuery' + - 'logs:StopQuery' + - 'logs:TestMetricFilter' + - 'logs:FilterLogEvents' + Resource: + - !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group:${LogGroupName}:*' + - !Sub 'arn:aws:logs:${AWS::Region}:${AWS::AccountId}:log-group::log-stream:*' + Roles: + - !Ref StateMachineRole + - !Ref LogQueryFunctionRole + +Outputs: + StateMachineArn: + Description: "State machine ARN" + Value: !Ref StateMachine + JiraApiTokenSecret: + Description: "Secrets Manager Secret with value of JiraAuthToken" + Value: !Ref JiraAuthTokenSecret