* Add functions to utils
  * store_oauth_token : Store an OAuth 2 access token in SSM parameter store
  * get_access_token : Fetch the OAuth 2 access token for a given client_id from cache or SSM
    parameter store
  * emit_to_mozdef : Send a message with the user's response to SQS for pickup by MozDef
  * call_slack : POST to a slack URL and return the result
  * provision_token : Given an OAuth 2 code, obtain a Slack access token and store it
  * redirect_to_slack_authorize : Build a Slack OAuth 2 authorization URL and redirect the user to it
* Fill out lambda_handler in app to cover all URL paths and direct invocations
* Add functions to app
  * API calls
    * process_api_call : Process an API Gateway call depending on the URL path called
      * `/authorize` : utils.redirect_to_slack_authorize
      * `/redirect_uri` : utils.provision_token
      * `/slack/interactive-endpoint` : handle_message_interaction : Process a user's interaction with a Slack message
        * send_slack_message_response : Respond to a user's selection by updating the Slack message with a reply
  * Direct invocations
    * send_message_to_slack : Send a message to a user via IM or Slack App conversation
      * get_user_from_email : Fetch a slack user dictionary for an email address
      * compose_message : Create a Slack message object
      * create_slack_channel : Create an IM channel with a user
      * post_message : Post a message to a slack channel
* Update README with configuration details
* Add details on discovering the SQS URL to the README
* Add additional test invocations to the Makefile
* Add SQS URL discovery to the Makefile
* Add requests to the requirements.txt
* Update CloudFormation template to
  * accept Slack client ID and secret
  * Grant Lambda function rights to
    * read and write to SSM parameter store
    * decrypt parameter store secrets
    * send messages to the SQS queue
  * Create the SQS queue
  * Grant the MozDef user rights to read from the SQS queue
* Add new settings to config.py
This commit is contained in:
Gene Wood 2019-12-27 22:52:14 -08:00
Родитель b0ece0d2c3
Коммит 03aa28745b
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: F0A9E7DCD39E452E
7 изменённых файлов: 855 добавлений и 38 удалений

Просмотреть файл

@ -5,39 +5,66 @@ A Slack bot that facilitates triaging MozDef alerts by automating outreach to Mo
### Deployment
To deploy the slack-triage-bot-api into AWS run
To deploy the slack-triage-bot-api into AWS
* determine the Slack Client Secret
* Run the make command for the environment you want
```shell script
make deploy-mozdef-slack-triage-bot-api
DEV_SLACK_CLIENT_SECRET=0123456789abcdef0123456789abcdef make deploy-mozdef-slack-triage-bot-api
```
or
```shell script
make deploy-mozdef-slack-triage-bot-api-dev
DEV_SLACK_CLIENT_SECRET=0123456789abcdef0123456789abcdef make deploy-mozdef-slack-triage-bot-api-dev
```
depending on the account
### Configuring in Slack
* Interactive Components
* Interactivity
* Request URL
* `https://example.com/slack/interactive-endpoint`
* Select Menus
* Options Load URL
* `https://example.com/slack/options-load-endpoint`
* OAuth & Permissions
* OAuth Tokens & Redirect URLs
* Redirect URLs
* `https://example.com/redirect_uri`
* Scopes
* Bot Token Scopes
* `users:read.email` : https://api.slack.com/methods/users.lookupByEmail
* `users:read` : This is required because of users:read.email
* `im:write` : https://api.slack.com/methods/conversations.open
* `chat:write` : https://api.slack.com/methods/chat.postMessage
* Install app
### Testing
You can test invoking the function with
You can test invoking the function by passing the email address of the user
you want to send a message to and calling
```shell script
make test-mozdef-slack-triage-bot-api-invoke
EMAIL_ADDRESS=user@example.com make test-mozdef-slack-triage-bot-api-invoke
```
which will pass
```json
{"foo": "baz"}
{
"identifier":"9Zo02m4B7gIfixq3c4Xh",
"alert":"duo_bypass_codes_generated",
"identityConfidence":"lowest",
"summary":"DUO bypass codes have been generated for your account. ",
"user":"user@example.com"
}
```
to the API which will return
```json
{"result": "bar"}
```
to the API which will return the JSON response from Slack of the message that
was sent to the user.
You can also test the API Gateway interface by running
@ -47,4 +74,18 @@ make test-mozdef-slack-triage-bot-api-http
which will hit the `/test` API endpoint and get back a 200 `API request received`
You can also visit the `/error` endpoint to get a 400 or any other endpoint to get a 404
You can also visit the `/error` endpoint to get a 400 or any other endpoint to get a 404
### Discovering the SQS URL containing user responses
To discover the URL of the SQS queue into which user responses are sent, call
```shell script
make discover-sqs-queue-url
```
which will return a value like
```json
{"result": "https://sqs.us-west-2.amazonaws.com/012345678901/MozDefSlackTraigeBotAPI-SlackTriageBotMozDefQueue-ABCDEFGHIJKL"}
```

Просмотреть файл

@ -10,6 +10,10 @@ PROD_DOMAIN_ZONE := security.mozilla.org.
DEV_DOMAIN_ZONE := security.allizom.org.
PROD_CERT_ARN := arn:aws:acm:us-west-2:371522382791:certificate/71e3c455-de1f-4a9d-b50e-440624854c48
DEV_CERT_ARN := arn:aws:acm:us-west-2:656532927350:certificate/2e59930d-ae8c-43fa-adff-ed3b1a902caa
PROD_SLACK_CLIENT_ID := ???
DEV_SLACK_CLIENT_ID := 371351187216.856548004901
# PROD_SLACK_CLIENT_SECRET := ???
# DEV_SLACK_CLIENT_SECRET := ???
.PHONE: deploy-mozdef-slack-triage-bot-api-dev
deploy-mozdef-slack-triage-bot-api-dev:
@ -21,7 +25,9 @@ deploy-mozdef-slack-triage-bot-api-dev:
$(CODE_STORAGE_S3_PREFIX) \
"CustomDomainName=$(DEV_DOMAIN_NAME) \
DomainNameZone=$(DEV_DOMAIN_ZONE) \
CertificateArn=$(DEV_CERT_ARN)" \
CertificateArn=$(DEV_CERT_ARN) \
SlackClientId=$(DEV_SLACK_CLIENT_ID) \
SlackClientSecret=$(DEV_SLACK_CLIENT_SECRET)" \
SlackTriageBotApiUrl
.PHONE: deploy-mozdef-slack-triage-bot-api
@ -34,7 +40,9 @@ deploy-mozdef-slack-triage-bot-api:
$(CODE_STORAGE_S3_PREFIX) \
"CustomDomainName=$(PROD_DOMAIN_NAME) \
DomainNameZone=$(PROD_DOMAIN_ZONE) \
CertificateArn=$(PROD_CERT_ARN)" \
CertificateArn=$(PROD_CERT_ARN) \
SlackClientId=$(PROD_SLACK_CLIENT_ID) \
SlackClientSecret=$(PROD_SLACK_CLIENT_SECRET)" \
SlackTriageBotApiUrl
.PHONE: test-mozdef-slack-triage-bot-api-http
@ -47,7 +55,58 @@ test-mozdef-slack-triage-bot-api-invoke:
FUNCTION_NAME=`aws cloudformation describe-stacks --stack-name $(STACK_NAME) --query "Stacks[0].Outputs[?OutputKey=='SlackTriageBotFunctionName'].OutputValue" --output text` && \
ACCESS_KEY=`aws cloudformation describe-stacks --stack-name $(STACK_NAME) --query "Stacks[0].Outputs[?OutputKey=='SlackTriageBotInvokerAccessKeyId'].OutputValue" --output text` && \
SECRET_KEY=`aws cloudformation describe-stacks --stack-name $(STACK_NAME) --query "Stacks[0].Outputs[?OutputKey=='SlackTriageBotInvokerSecretAccessKey'].OutputValue" --output text` && \
AWS_ACCESS_KEY_ID=$$ACCESS_KEY AWS_SECRET_ACCESS_KEY=$$SECRET_KEY AWS_SESSION_TOKEN= aws lambda invoke --function-name $$FUNCTION_NAME --payload '{"foo": "baz"}' response.json && \
AWS_ACCESS_KEY_ID=$$ACCESS_KEY AWS_SECRET_ACCESS_KEY=$$SECRET_KEY AWS_SESSION_TOKEN= aws lambda invoke \
--function-name $$FUNCTION_NAME \
--payload '{"identifier": "9Zo02m4B7gIfixq3c4Xh", "alert": "duo_bypass_codes_generated", "identityConfidence": "highest", "summary": "DUO bypass codes have been generated for your account. ", "user": "$(EMAIL_ADDRESS)"}' \
response.json && \
cat response.json && \
rm response.json
.PHONE: test-mozdef-slack-triage-bot-api-invoke-ssh1
test-mozdef-slack-triage-bot-api-invoke-ssh1:
FUNCTION_NAME=`aws cloudformation describe-stacks --stack-name $(STACK_NAME) --query "Stacks[0].Outputs[?OutputKey=='SlackTriageBotFunctionName'].OutputValue" --output text` && \
ACCESS_KEY=`aws cloudformation describe-stacks --stack-name $(STACK_NAME) --query "Stacks[0].Outputs[?OutputKey=='SlackTriageBotInvokerAccessKeyId'].OutputValue" --output text` && \
SECRET_KEY=`aws cloudformation describe-stacks --stack-name $(STACK_NAME) --query "Stacks[0].Outputs[?OutputKey=='SlackTriageBotInvokerSecretAccessKey'].OutputValue" --output text` && \
AWS_ACCESS_KEY_ID=$$ACCESS_KEY AWS_SECRET_ACCESS_KEY=$$SECRET_KEY AWS_SESSION_TOKEN= aws lambda invoke \
--function-name $$FUNCTION_NAME \
--payload '{"identifier": "9Zo02m4B7gIfixq3c4Xh", "alert": "sensitive_host_session", "identityConfidence": "low", "summary": "An SSH session to a potentially sensitive host sensitive.example.com was made by your user account.", "user": "$(EMAIL_ADDRESS)"}' \
response.json && \
cat response.json && \
rm response.json
.PHONE: test-mozdef-slack-triage-bot-api-invoke-ssh2
test-mozdef-slack-triage-bot-api-invoke-ssh2:
FUNCTION_NAME=`aws cloudformation describe-stacks --stack-name $(STACK_NAME) --query "Stacks[0].Outputs[?OutputKey=='SlackTriageBotFunctionName'].OutputValue" --output text` && \
ACCESS_KEY=`aws cloudformation describe-stacks --stack-name $(STACK_NAME) --query "Stacks[0].Outputs[?OutputKey=='SlackTriageBotInvokerAccessKeyId'].OutputValue" --output text` && \
SECRET_KEY=`aws cloudformation describe-stacks --stack-name $(STACK_NAME) --query "Stacks[0].Outputs[?OutputKey=='SlackTriageBotInvokerSecretAccessKey'].OutputValue" --output text` && \
AWS_ACCESS_KEY_ID=$$ACCESS_KEY AWS_SECRET_ACCESS_KEY=$$SECRET_KEY AWS_SESSION_TOKEN= aws lambda invoke \
--function-name $$FUNCTION_NAME \
--payload '{"identifier": "9Zo02m4B7gIfixq3c4Xh", "alert": "ssh_access_sign_releng", "identityConfidence": "low", "summary": "An SSH session was established to host host.example.com by your user account.", "user": "$(EMAIL_ADDRESS)"}' \
response.json && \
cat response.json && \
rm response.json
.PHONE: test-mozdef-slack-triage-bot-api-invoke-duo-code-used
test-mozdef-slack-triage-bot-api-invoke-duo-code-used:
FUNCTION_NAME=`aws cloudformation describe-stacks --stack-name $(STACK_NAME) --query "Stacks[0].Outputs[?OutputKey=='SlackTriageBotFunctionName'].OutputValue" --output text` && \
ACCESS_KEY=`aws cloudformation describe-stacks --stack-name $(STACK_NAME) --query "Stacks[0].Outputs[?OutputKey=='SlackTriageBotInvokerAccessKeyId'].OutputValue" --output text` && \
SECRET_KEY=`aws cloudformation describe-stacks --stack-name $(STACK_NAME) --query "Stacks[0].Outputs[?OutputKey=='SlackTriageBotInvokerSecretAccessKey'].OutputValue" --output text` && \
AWS_ACCESS_KEY_ID=$$ACCESS_KEY AWS_SECRET_ACCESS_KEY=$$SECRET_KEY AWS_SESSION_TOKEN= aws lambda invoke \
--function-name $$FUNCTION_NAME \
--payload '{"identifier": "9Zo02m4B7gIfixq3c4Xh", "alert": "duo_bypass_codes_used", "identityConfidence": "highest", "summary": "DUO bypass codes belonging to your account have been used to ", "user": "$(EMAIL_ADDRESS)"}' \
response.json && \
cat response.json && \
rm response.json
.PHONE: discover-sqs-queue-url
discover-sqs-queue-url:
FUNCTION_NAME=`aws cloudformation describe-stacks --stack-name $(STACK_NAME) --query "Stacks[0].Outputs[?OutputKey=='SlackTriageBotFunctionName'].OutputValue" --output text` && \
ACCESS_KEY=`aws cloudformation describe-stacks --stack-name $(STACK_NAME) --query "Stacks[0].Outputs[?OutputKey=='SlackTriageBotInvokerAccessKeyId'].OutputValue" --output text` && \
SECRET_KEY=`aws cloudformation describe-stacks --stack-name $(STACK_NAME) --query "Stacks[0].Outputs[?OutputKey=='SlackTriageBotInvokerSecretAccessKey'].OutputValue" --output text` && \
AWS_ACCESS_KEY_ID=$$ACCESS_KEY AWS_SECRET_ACCESS_KEY=$$SECRET_KEY AWS_SESSION_TOKEN= aws lambda invoke \
--function-name $$FUNCTION_NAME \
--payload '{"action": "discover-sqs-queue-url"}' \
response.json && \
cat response.json && \
rm response.json

Просмотреть файл

@ -1,9 +1,17 @@
import json
import logging
import traceback
import urllib.parse
import requests
from .config import CONFIG
from .utils import (
foo
call_slack,
emit_to_mozdef,
provision_token,
redirect_to_slack_authorize,
SlackException
)
logger = logging.getLogger()
@ -13,9 +21,394 @@ logging.getLogger('botocore').propagate = False
logging.getLogger('urllib3').propagate = False
def send_message_to_slack() -> str:
result = foo()
return result
def get_user_from_email(email: str) -> dict:
"""Fetch a slack user dictionary for an email address
Required slack scopes
* bot - users:read.email : https://api.slack.com/methods/users.lookupByEmail
* bot - users:read : This scope must be requested if users:read.email is
requested
:param email: email address of the slack user
:return: dictionary of user information
"""
data = {'email': email}
url = 'https://slack.com/api/users.lookupByEmail'
return call_slack(url, data, 'user')
def create_slack_channel(user: str) -> dict:
"""Create an IM channel with a user
Required slack scopes
* bot - im:write : https://api.slack.com/methods/conversations.open
:param user: The Slack user ID of the user to create an IM channel with
:return: dictionary of channel information
"""
data = {'users': user}
url = 'https://slack.com/api/conversations.open'
return call_slack(url, data, 'channel', True)
def compose_message(
identifier: str,
alert: str,
summary: str,
email: str,
identity_confidence: str) -> dict:
"""Create a Slack message object
:param identifier: The unique identifier for this message
:param alert: The name of the MozDef alert
:param summary: The summary text of the alert
:param email: The email address of the user
:param identity_confidence: The identity confidence sent from MozDef
:return: A Slack message dictionary
"""
default_response = {
'identifier': identifier,
'email': email,
'alert': alert,
'identity_confidence': identity_confidence
}
# TODO : Add something that if the identity_confidence is high don't offer
# the wronguser option
blocks = [
{
"block_id": "mozdef-triage-bot-api-question",
"text": {
"text": "{}\nWas this action taken by you ({})?".format(
summary, email),
"type": "mrkdwn",
},
"type": "section"
},
{
"block_id": "mozdef-triage-bot-api-answer",
"type": "actions",
"elements": [
{
"action_id": "mozdef-triage-bot-api-yes",
"style": "primary",
"text": {
"emoji": False,
"text": "Yes, I did that",
"type": "plain_text"
},
"type": "button",
"value": json.dumps(
{**default_response, "response": "yes"})
},
{
"action_id": "mozdef-triage-bot-api-no",
"style": "danger",
"text": {
"emoji": False,
"text": "No, I didn't do that!",
"type": "plain_text",
},
"type": "button",
"confirm": {
"confirm": {
"text": "Ya, I didn't take that action",
"type": "plain_text"
},
"deny": {
"text": "Oh, nevermind, I did do that",
"type": "plain_text"
},
"text": {
"text": "Are you sure that you didn't take that "
"action? If you're sure then someone in "
"the security team will contact you to "
"follow up.".format(email),
"type": "mrkdwn"
},
"title": {
"text": "Are you sure?", "type": "plain_text"}
},
"value": json.dumps({**default_response, "response": "no"})
}
]
}
]
if identity_confidence in ['moderate', 'low', 'lowest']:
blocks[1]['elements'].append(
{
"action_id": "mozdef-triage-bot-api-wronguser",
"text": {
"text": "You've got the wrong person",
"type": "plain_text"
},
"confirm": {
"confirm": {
"text": "Ya, that's not me",
"type": "plain_text"
},
"deny": {
"text": "Oh, actually that is me",
"type": "plain_text"
},
"text": {
"text": (
"Are you sure that {} isn't you and we've sent "
"this alert to the wrong user?".format(email)),
"type": "mrkdwn"
},
"title": {"text": "Are you sure?", "type": "plain_text"}
},
"type": "button",
"value": json.dumps(
{**default_response, "response": "wronguser"})
}
)
blocks[1]['elements'].append(
{
"action_id": "mozdef-triage-bot-api-notsure",
"text": {
"text": "Hmm... I'm not sure",
"type": "plain_text"
},
"type": "button",
"value": json.dumps({**default_response, "response": "notsure"})
}
)
blocks_json = json.dumps(blocks)
# We can't pass "as_user": False here as doing so causes the Slack API to
# return an error that the chat:write:bot scope is missing
# Slack support reports this is a known bug
# https://mozilla-sandbox-scim.slack.com/help/requests/2564183
message = {
'blocks': blocks_json,
'text': summary
}
return message
def post_message(channel: str, message: dict) -> dict:
"""Post a message to a slack channel
Required slack scopes
* bot - chat:write : https://api.slack.com/methods/chat.postMessage
:param channel: The Slack channel ID to post the message to
:param message: The message to post
:return: A slack message dictionary
"""
data = message
data['channel'] = channel
url = 'https://slack.com/api/chat.postMessage'
return call_slack(url, data, 'message', True)
def send_message_to_slack(
identifier: str,
alert: str,
summary: str,
email_address: str,
identity_confidence: str) -> dict:
"""Send a message to a user via IM or Slack App conversation
:param identifier: The unique identifier sent by MozDef originally
:param alert: The name of the MozDef alert
:param summary: The summary text of the alert
:param email_address: The user's email address
:param identity_confidence: The identityConfidence sent by MozDef
originally
:return: A slack message dictionary
"""
send_to_im = False
user = get_user_from_email(email_address)
message = compose_message(
identifier, alert, summary, email_address, identity_confidence)
if send_to_im:
channel = create_slack_channel(user['id'])
post_result = post_message(channel['id'], message)
else:
post_result = post_message(user['id'], message)
return post_result
def send_slack_message_response(
response_url: str,
message: dict,
user_response: str) -> bool:
"""Respond to a user's selection by updating the Slack message with a reply
:param response_url: The Slack URL to send message responses to
:param message: The original message that the user made a selection from
:param user_response: The user's selection
:return: Whether or not the response to the user succeeded
"""
if user_response == 'yes':
bot_response = (
':heavy_check_mark: Understood, thanks for letting us know.')
elif user_response == 'no':
bot_response = (
':open_mouth: Got it, thank you. Someone from the security team '
'will contact you to follow up on this.')
elif user_response == 'wronguser':
bot_response = (
":flushed: Oh, sorry about that. Someone from the security team "
"will look into this and contact the right user. Sorry to bother "
"you.")
elif user_response == 'notsure':
bot_response = (
":ok_hand: No problem. Someone from the security team will "
"contact you to follow up on this.")
else:
bot_response = (
":heavy_multiplication_x: Hmm, I had some kind of internal error. "
"Would you contact the security team to let them know that I'm "
"unwell?")
if 'mozdef-triage-bot-api-response' in [x.get('block_id') for x
in message.get('blocks', [])]:
bot_response = "You've changed your mind, no problem. " + bot_response
response_block = {
'block_id': 'mozdef-triage-bot-api-response',
'text': {
'text': bot_response,
'type': "mrkdwn"
},
"type": "section"
}
if 'blocks' in message:
for i in range(0, len(message['blocks'])):
if message['blocks'][i].get('block_id') == 'mozdef-triage-bot-api-response':
message['blocks'][i] = response_block
break
else:
message['blocks'].append(response_block)
message['replace_original'] = True
try:
response = requests.post(
url=response_url,
json=message
)
response.raise_for_status()
except requests.exceptions.RequestException as e:
logger.error(
'POST of response to {} failed {} : {} : {} : {}'.format(
response_url,
message,
e,
getattr(e.response, 'status_code', None),
getattr(e.response, 'text', None)
))
return False
return True
def handle_message_interaction(payload: dict) -> bool:
"""Process a user's interaction with a Slack message
payload['type'] :
'block_actions' : Parse the value that the user chose, send it to
MozDef and send a response to the user
:param payload: A dictionary of data sent from Slack about a user's
interaction
:return: Whether or not the response to the user succeeded
"""
# The right way to do this is
# 1. Drop a message in a message queue (e.g. SQS) with the message to
# send back to the user
# 2. Return 200
# 3. Pull that message off the queue
# 4. POST to the response_url with the response message
# Until that's added we'll just
# 1. POST to the response_url with the response message
# 2. Hope that the POST completes in under 3 seconds and return 200
if payload.get('type') == 'block_actions':
# User clicked a Block Kit interactive component
for action in payload.get('actions', []):
if 'value' not in action:
raise SlackException(
'Action encountered with no value : {}'.format(action))
try:
value = json.loads(action['value'])
except json.decoder.JSONDecodeError as e:
logger.error('Failed to parse button value "{}" : {}'.format(
action['value'],
e
))
raise
message_id = emit_to_mozdef(
value.get('identifier'),
value.get('email'),
payload.get('user', {}).get('id'),
value['identity_confidence'],
value.get('response')
)
allowed_keys = [
'text', 'blocks', 'attachments', 'thread_ts', 'mrkdwn']
original_message = {
k: v for k, v in
payload.get('message', {}).items()
if k in allowed_keys}
return send_slack_message_response(
payload.get('response_url'),
original_message,
value.get('response'))
else:
# https://api.slack.com/interactivity/handling#payloads
logger.error(
"Encountered a message interaction payload type that hasn't yet "
"been developed : {}".format(payload))
return False
def process_api_call(
event: dict,
query_string_parameters: dict,
body: dict) -> dict:
"""Process an API Gateway call depending on the URL path called
:param event: The API Gateway request event
:param query_string_parameters: A dictionary of query string parameters
:param body: The parsed body that was POSTed to the API Gateway
:return: A dictionary of an API Gateway HTTP response
"""
if event.get('path') == '/error':
return {
'headers': {'Content-Type': 'text/html'},
'statusCode': 400,
'body': "Since you requested the /error API endpoint I'll go "
"ahead and serve back a 400"}
elif event.get('path') == '/test':
return {
'headers': {'Content-Type': 'text/html'},
'statusCode': 200,
'body': 'API request received'}
elif event.get('path') == '/redirect_uri':
return provision_token(query_string_parameters)
elif event.get('path') == '/authorize':
return redirect_to_slack_authorize()
elif event.get('path') == '/slack/interactive-endpoint':
for payload_raw in body.get('payload', []):
payload = json.loads(payload_raw)
logger.debug('payload is {}'.format(payload))
result = handle_message_interaction(payload)
return {
'headers': {'Content-Type': 'text/html'},
'statusCode': 200,
'body': 'Acknowledged'}
elif event.get('path') == '/slack/options-load-endpoint':
# https://api.slack.com/reference/block-kit/block-elements#external_select
return {
'headers': {'Content-Type': 'text/html'},
'statusCode': 200,
'body': 'Acknowledged'}
else:
return {
'headers': {'Content-Type': 'text/html'},
'statusCode': 404,
'body': "That path wasn't found"}
def lambda_handler(event: dict, context: dict) -> dict:
@ -28,25 +421,36 @@ def lambda_handler(event: dict, context: dict) -> dict:
logger.debug('event is {}'.format(event))
try:
if event.get('resource') == '/{proxy+}':
# this invocation comes from API Gateway
if event.get('path') == '/error':
return {
'headers': {'Content-Type': 'text/html'},
'statusCode': 400,
'body': "Since you requested the /error API endpoint I'll go ahead and serve back a 400"}
elif event.get('path') == '/test':
return {
'headers': {'Content-Type': 'text/html'},
'statusCode': 200,
'body': 'API request received'}
headers = event['headers'] if event['headers'] is not None else {}
cookie_header = headers.get('Cookie', '')
referer = headers.get('Referer', '')
query_string_parameters = (
event['queryStringParameters']
if event['queryStringParameters'] is not None else {})
parser = None
if headers.get('Content-Type') == 'application/x-www-form-urlencoded':
parser = urllib.parse.parse_qs
elif headers.get('Content-Type') == 'application/json':
parser = json.loads
if parser is not None and event.get('body') is not None:
body = parser(event['body'])
else:
return {
'headers': {'Content-Type': 'text/html'},
'statusCode': 404,
'body': "That path wasn't found"}
body = {}
return process_api_call(event, query_string_parameters, body)
else:
# Not an API Gateway invocation, we'll assume a direct Lambda invocation
return {'result': send_message_to_slack()}
if event.get('action') == 'discover-sqs-queue-url':
result = CONFIG.queue_url
else:
result = send_message_to_slack(
event.get('identifier'),
event.get('alert'),
event.get('summary'),
event.get('user'),
event.get('identityConfidence')
)
return {'result': result}
except Exception as e:
logger.error(str(e))
logger.error(traceback.format_exc())

Просмотреть файл

@ -3,8 +3,14 @@ import os
class Config:
def __init__(self):
self.parameter_store_prefix = '/SlackTriageBot'
self.slack_token_parameter_store_name = '{}/SlackOAuthToken'.format(
self.parameter_store_prefix)
self.domain_name = os.getenv('DOMAIN_NAME')
self.log_level = os.getenv('LOG_LEVEL', 'INFO')
self.slack_client_id = os.getenv('SLACK_CLIENT_ID')
self.slack_client_secret = os.getenv('SLACK_CLIENT_SECRET')
self.queue_url = os.getenv('QUEUE_URL')
CONFIG = Config()

Просмотреть файл

@ -1,4 +1,8 @@
import logging
import json
from typing import Optional
import boto3
import requests
from .config import CONFIG
@ -6,5 +10,222 @@ logger = logging.getLogger(__name__)
logger.setLevel(CONFIG.log_level)
def foo() -> str:
return 'bar'
class SlackException(Exception):
pass
def store_oauth_token(client_id: str, access_token: str) -> dict:
"""Store an OAuth 2 access token in SSM parameter store
:param client_id: OAuth 2 client_id issued by Slack
:param access_token: OAuth 2 access token
:return: dictionary containing the "Version" and "Tier" of the stored
parameter
"""
client = boto3.client('ssm')
return client.put_parameter(
Name='{}-{}'.format(
CONFIG.slack_token_parameter_store_name, client_id),
Description='The Slack OAuth access token for the MozDef Slack Triage '
'Bot API',
Value=access_token,
Type='SecureString',
Overwrite=True,
Tags=[
{
'Key': 'Application',
'Value': 'MozDef Slack Triage Bot API'
},
]
)
def get_access_token(client_id: str) -> dict:
"""Fetch the OAuth 2 access token for a given client_id from cache or SSM
parameter store
:param client_id: The OAuth 2 client_id to fetch the associated access
token from
:return: string of the access token
"""
global access_token
if 'access_token' not in globals():
access_token = {}
if client_id not in access_token:
client = boto3.client('ssm')
response = client.get_parameter(
Name='{}-{}'.format(
CONFIG.slack_token_parameter_store_name, client_id),
WithDecryption=True
)
access_token[client_id] = response['Parameter']['Value']
return access_token
def emit_to_mozdef(
identifier: str,
email: str,
slack_user_id: str,
identity_confidence: str,
response: str) -> str:
"""Send a message with the user's response to SQS for pickup by MozDef
:param identifier: The unique identifier sent by MozDef originally
:param email: The user's email address
:param slack_user_id: The user's slack ID
:param identity_confidence: The identityConfidence sent by MozDef
originally
:param response: The user's response
:return: The message ID returned from SQS after sending the message
"""
data = {
"identifier": identifier,
"user": {
"email": email,
"slack": slack_user_id
},
"identityConfidence": identity_confidence,
"response": response
}
client = boto3.client('sqs')
response = client.send_message(
QueueUrl=CONFIG.queue_url,
MessageBody=json.dumps(data)
)
return response['MessageId']
def call_slack(
url: str,
data: dict,
key_to_return: str,
post_as_json: Optional[bool] = False) -> dict:
"""POST to a slack URL and return the result
:param url: The Slack URL to POST to
:param data: The payload to pass in the POST body
:param key_to_return: The key in the dictionary that is returned by Slack
to return to the caller of the call_slack method
:param post_as_json: A boolean of whether or not to POST a JSON payload
or a URL encoded payload
:return: The response from Slack based on the key_to_return
"""
access_token = get_access_token(CONFIG.slack_client_id)
headers = {
'Authorization': 'Bearer {}'.format(
access_token[CONFIG.slack_client_id])}
try:
if post_as_json:
response = requests.post(url, json=data, headers=headers)
else:
response = requests.post(url, data=data, headers=headers)
response.raise_for_status()
if not response.json().get('ok'):
raise SlackException(response.json().get('error'))
except requests.exceptions.RequestException as e:
logger.error(
'POST of response to {} failed {} : {} : {} : {}'.format(
url,
data,
e,
getattr(e.response, 'status_code', None),
getattr(e.response, 'text', None)
))
raise
logger.debug('Called slack with {} and received response of {}'.format(
data,
response.json()
))
return response.json().get(key_to_return)
def provision_token(query_string_parameters: dict) -> dict:
"""Given an OAuth 2 code, obtain a Slack access token and store it
:param query_string_parameters: A dictionary of the URL query parameters
:return: A dictionary of an API Gateway HTTP response
"""
if query_string_parameters.get('error'):
logger.error('redirect_uri error : {}'.format(
query_string_parameters.get('error')
))
return {
'headers': {'Content-Type': 'text/html'},
'statusCode': 400,
'body': "Unable to provision and store an OAuth access token"}
data = {
'code': query_string_parameters.get('code'),
'client_id': CONFIG.slack_client_id,
'client_secret': CONFIG.slack_client_secret
}
url = 'https://slack.com/api/oauth.v2.access'
try:
response = requests.post(
url=url,
data=data
)
response.raise_for_status()
if not response.json().get('ok'):
raise SlackException(response.json().get('error'))
except (requests.exceptions.RequestException, SlackException) as e:
logger.error(
'Failed to provision and store OAuth access token with url {} '
'and data {} : {} : {} : {}'.format(
url,
data,
e,
getattr(e.response, 'status_code', None),
getattr(e.response, 'text', None)
))
return {
'headers': {'Content-Type': 'text/html'},
'statusCode': 400,
'body': "Unable to provision and store an OAuth access token"}
access_token = response.json().get('access_token')
if access_token is not None:
store_oauth_token(CONFIG.slack_client_id, access_token)
return {
'headers': {'Content-Type': 'text/html'},
'statusCode': 200,
'body': 'Success : OAuth access token has been '
'provisioned and stored'}
def redirect_to_slack_authorize() -> dict:
"""Build a Slack OAuth 2 authorization URL and redirect the user to it
:return: A dictionary of an API Gateway HTTP response
"""
redirect_uri = 'https://{}/redirect_uri'.format(
CONFIG.domain_name)
# TODO : Do we want to pass a "state" argument here? If so we'll need
# to store it in the client's cookies or somewhere
# users:read scope must be requested at the same time as
# users:read.email or the error "Invalid permissions requested" is
# returned
scopes = [
'chat:write',
'users:read',
'users:read.email',
'im:write'
]
url = (
'https://slack.com/oauth/v2/authorize?'
'redirect_uri={redirect_uri}&'
'client_id={client_id}&'
'scope={scopes}'.format(
redirect_uri=redirect_uri,
client_id=CONFIG.slack_client_id,
scopes=' '.join(scopes)
))
return {
'statusCode': 302,
'headers': {
'Location': url,
'Cache-Control': 'max-age=0',
'Content-Type': 'text/html'
},
'body': 'Redirecting to identity provider'
}

Просмотреть файл

@ -0,0 +1 @@
requests

Просмотреть файл

@ -10,6 +10,11 @@ Metadata:
- CustomDomainName
- DomainNameZone
- CertificateArn
- Label:
default: Slack
Parameters:
- SlackClientId
- SlackClientSecret
ParameterLabels:
CustomDomainName:
default: Custom DNS Domain Name
@ -17,6 +22,10 @@ Metadata:
default: DNS Zone containing the Custom DNS Domain Name
CertificateArn:
default: AWS ACM Certificate ARN for the Custom DNS Domain Name
SlackClientId:
default: Slack App OAuth client ID
SlackClientSecret:
default: Slack App OAuth client secret
Parameters:
CustomDomainName:
Type: String
@ -30,6 +39,13 @@ Parameters:
Type: String
Description: The ARN of the AWS ACM Certificate for your custom domain name
Default: ''
SlackClientId:
Type: String
Description: Slack App OAuth client ID
SlackClientSecret:
Type: String
NoEcho: true
Description: Slack App OAuth client secret
Conditions:
UseCustomDomainName: !Not [ !Equals [ !Ref 'CustomDomainName', '' ] ]
Rules:
@ -62,6 +78,37 @@ Resources:
- logs:CreateLogStream
- logs:PutLogEvents
Resource: '*'
- PolicyName: AllowReadWriteParameterStore
PolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Action:
- ssm:GetParameter
- ssm:PutParameter
- ssm:AddTagsToResource
Resource: !Join [ '', [ 'arn:aws:ssm:', !Ref 'AWS::Region', ':', !Ref 'AWS::AccountId', ':parameter/SlackTriageBot/*' ] ]
- PolicyName: AllowEncryptDecryptParameterStore
PolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Action:
- kms:Decrypt
- kms:Encrypt
Resource: '*' # This is a wildcard because determining the CMK ARN for the aws/ssm KMS key is difficult
Condition:
StringLike:
'kms:EncryptionContext:PARAMETER_ARN': !Join [ '', [ 'arn:aws:ssm:', !Ref 'AWS::Region', ':', !Ref 'AWS::AccountId', ':parameter/SlackTriageBot/*' ] ]
- PolicyName: AllowSendSlackTriageBotSQSQueue
PolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Action:
- sqs:SendMessage
Resource:
- !GetAtt SlackTriageBotMozDefQueue.Arn
SlackTriageBotApiFunction:
Type: AWS::Lambda::Function
Properties:
@ -70,7 +117,10 @@ Resources:
Environment:
Variables:
DOMAIN_NAME: !Ref CustomDomainName # What if a domain name isn't provided?
LOG_LEVEL: DEBUG
SLACK_CLIENT_ID: !Ref SlackClientId
SLACK_CLIENT_SECRET: !Ref SlackClientSecret
QUEUE_URL: !Ref SlackTriageBotMozDefQueue
LOG_LEVEL: INFO
Handler: slack_triage_bot_api.app.lambda_handler
Runtime: python3.7
Role: !GetAtt SlackTriageBotApiFunctionRole.Arn
@ -205,6 +255,17 @@ Resources:
Uri: !Join [ '', [ 'arn:aws:apigateway:', !Ref 'AWS::Region', ':lambda:path/2015-03-31/functions/', !GetAtt 'SlackTriageBotApiFunction.Arn', '/invocations' ] ]
ResourceId: !Ref SlackTriageBotApiResource
RestApiId: !Ref SlackTriageBotApiApi
SlackTriageBotMozDefQueue:
Type: AWS::SQS::Queue
Properties:
MessageRetentionPeriod: 345600 # 4 days, the AWS default
Tags:
- Key: application
Value: slack-triage-bot-api
- Key: stack
Value: !Ref AWS::StackName
- Key: source
Value: https://github.com/mozilla/MozDef-Triage-Bot
SlackTriageBotInvokerUser:
Type: AWS::IAM::User
Properties:
@ -218,6 +279,21 @@ Resources:
- lambda:InvokeFunction
Resource:
- !GetAtt SlackTriageBotApiFunction.Arn
- PolicyName: AllowReceiveSlackTriageBotSQSQueue
PolicyDocument:
Version: 2012-10-17
Statement:
- Effect: Allow
Action:
- sqs:ChangeMessageVisibility
- sqs:DeleteMessage
- sqs:GetQueueAttributes
- sqs:GetQueueUrl
- sqs:ListQueueTags
- sqs:PurgeQueue
- sqs:ReceiveMessage
Resource:
- !GetAtt SlackTriageBotMozDefQueue.Arn
SlackTriageBotInvokerAccessKey:
Type: AWS::IAM::AccessKey
Properties:
@ -244,3 +320,12 @@ Outputs:
SlackTriageBotInvokerSecretAccessKey:
Description: The AWS API Access Key Secret Key of the SlackTriageBotInvoker
Value: !GetAtt SlackTriageBotInvokerAccessKey.SecretAccessKey
SlackTriageBotMozDefSQSQueueArn:
Description: The ARN of the MozDef SQS Queue
Value: !GetAtt SlackTriageBotMozDefQueue.Arn
SlackTriageBotMozDefSQSQueueName:
Description: The Name of the MozDef SQS Queue
Value: !GetAtt SlackTriageBotMozDefQueue.QueueName
SlackTriageBotMozDefSQSQueueUrl:
Description: The Url of the MozDef SQS Queue
Value: !Ref SlackTriageBotMozDefQueue