Skip to content

Commit

Permalink
[outputs] add Microsoft Teams as an alerting output (#1079)
Browse files Browse the repository at this point in the history
* [core] Initial Microsoft Teams output code commit, looking for feedback

Signed-off-by: jack1902 <[email protected]>

* [testing] added TeamsOutput Testing (used slack tests as template), ammended list for output_base aswell

Signed-off-by: jack1902 <[email protected]>

* [docs] added Microsoft Teams to output documentation
[setup] added pymsteams to reuirements-top-level and added sample-webhook to outputs.json

Signed-off-by: jack1902 <[email protected]>

* [core] Moved pymsteams to package.py
[docs] Corrected docstring for teams and added teams to outputs
[core] Added Alert section to card (didn't have the alert_id which made
it confusing previously)
[testing] re-wrote the tests

Signed-off-by: jack1902 <[email protected]>
  • Loading branch information
jack1902 authored and chunyong-lin committed Jan 27, 2020
1 parent b6c7960 commit b3638c4
Show file tree
Hide file tree
Showing 8 changed files with 789 additions and 4 deletions.
5 changes: 4 additions & 1 deletion conf/outputs.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,5 +25,8 @@
],
"slack": [
"sample-channel"
],
"teams": [
"sample-webhook"
]
}
}
3 changes: 2 additions & 1 deletion docs/source/outputs.rst
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Out of the box, StreamAlert supports:
* **PagerDuty**
* **Phantom**
* **Slack**
* **Microsoft Teams**

StreamAlert can be extended to support any API. Creating a new output to send alerts to is easily accomplished through inheritance from the ``StreamOutputBase`` class. More on that in the `Adding Support for New Services`_ section below.

Expand All @@ -41,7 +42,7 @@ Adding a new configuration for a currently supported service is handled using ``
- ``<SERVICE_NAME>`` above should be one of the following supported service identifiers:
``aws-cloudwatch-log``, ``aws-firehose``, ``aws-lambda``, ``aws-s3``, ``aws-sns``, ``aws-sqs``,
``carbonblack``, ``github``, ``jira``, ``komand``, ``pagerduty``, ``pagerduty-incident``,
``pagerduty-v2``, ``phantom``, ``slack``
``pagerduty-v2``, ``phantom``, ``slack``, ``teams``

For example:
- ``python manage.py output slack``
Expand Down
1 change: 1 addition & 0 deletions requirements-top-level.txt
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ pathlib2
policyuniverse
pyfakefs
pylint==2.3.1
pymsteams
requests
Sphinx
sphinx-rtd-theme
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ pycparser==2.19
pyflakes==2.1.1
Pygments==2.4.2
PyJWT==1.7.1
pymsteams==0.1.12
pyparsing==2.4.2
pyrsistent==0.15.5
pytest==5.0.0
Expand Down
260 changes: 260 additions & 0 deletions streamalert/alert_processor/outputs/teams.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
"""
Copyright 2017-present, Airbnb Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
"""
from collections import OrderedDict

import pymsteams
from pymsteams import TeamsWebhookException

from streamalert.alert_processor.helpers import compose_alert
from streamalert.alert_processor.outputs.output_base import (
OutputDispatcher,
OutputProperty,
StreamAlertOutput,
)
from streamalert.shared.logger import get_logger

LOGGER = get_logger(__name__)


@StreamAlertOutput
class TeamsOutput(OutputDispatcher):
"""TeamsOutput handles all alert dispatching for Microsoft Teams"""

__service__ = "teams"

@classmethod
def get_user_defined_properties(cls):
"""Get properties that must be assigned by the user when configuring a new Microsoft Teams
output. This should be sensitive or unique information for this use-case that needs
to come from the user.
Every output should return a dict that contains a 'descriptor' with a description of the
integration being configured.
Microsoft Teams also requires a user provided 'webhook' url that is composed of the Team's
api url and the unique integration key for this output. This value should be should be
masked during input and is a credential requirement.
Returns:
OrderedDict: Contains various OutputProperty items
"""
return OrderedDict(
[
(
"descriptor",
OutputProperty(
description="a short and unique descriptor for this service configuration "
"(ie: name of Team the webhook relates too)"
),
),
(
"url",
OutputProperty(
description="the full teams webhook url, including the secret",
mask_input=True,
cred_requirement=True,
),
),
]
)

@classmethod
def _format_message(cls, alert, publication, webhook_url):
"""Format the message to be sent to Teams
Args:
alert (Alert): The alert
publication (dict): Alert relevant to the triggered rule
webhook_url (str): The webhook_url to send the card too
Returns:
pymsteams.connectorcard: The message to be sent to Teams
The card will look like (by Default):
StreamAlert Rule Triggered: rule_name
Rule Description:
This will be the docstring from the rule, sent as the rule_description
Record:
key value
key value
...
"""
# Presentation defaults
default_title = "StreamAlert Rule Triggered: {}".format(alert.rule_name)
default_description = alert.rule_description
default_color = "E81123" # Red in Hexstring format

# Special field that Publishers can use to customize the message
title = publication.get("@teams.title", default_title)
description = publication.get("@teams.description", default_description)
card_color = publication.get("@teams.card_color", default_color)
with_record = publication.get("@teams.with_record", True)

# Instantiate the card with the url
teams_card = pymsteams.connectorcard(webhook_url)

# Set the cards title, text and color
teams_card.title(title)
teams_card.text(description)
teams_card.color(card_color)

# Add the Alert Section
teams_card.addSection(cls._generate_alert_section(alert))

if with_record:
# Add the record Section
teams_card.addSection(cls._generate_record_section(alert.record))

if "@teams.additional_card_sections" in publication:
teams_card = cls._add_additional_sections(
teams_card, publication["@teams.additional_card_sections"]
)

return teams_card

@classmethod
def _generate_record_section(cls, record):
"""Generate the record section
This adds the entire record to a section as key/value pairs
Args:
record (dict): asd
Returns:
record_section (pymsteams.cardsection): record section for the outgoing card
"""
# Instantiate the card section
record_section = pymsteams.cardsection()

# Set the title
record_section.activityTitle("StreamAlert Alert Record")

# Add the record as key/value pairs
for key, value in record.items():
record_section.addFact(key, str(value))

return record_section

@classmethod
def _generate_alert_section(cls, alert):
"""Generate the alert section
Args:
alert (Alert): The alert
Returns:
alert_section (pymsteams.cardsection): alert section for the outgoing card
"""

# Instantiate the card
alert_section = pymsteams.cardsection()

# Set the title
alert_section.activityTitle("Alert Info")

# Add basic information to the alert section
alert_section.addFact("rule_name", alert.rule_name)
alert_section.addFact("alert_id", alert.alert_id)

return alert_section

@staticmethod
def _add_additional_sections(teams_card, additional_sections):
"""Add additional card sections to the teams card
Args:
teams_card (pymsteams.connectorcard): Teams connector card
additional_sections (list[pymsteams.cardsection]):
Additional sections to be added to the card. Each section should be of
type: pymsteams.cardsection and have their relevant fields filled out.
Please review the pymsteams documentation for additional information.
Returns:
teams_card (pymsteams.connectorcard): teams_card with additional sections added
"""
if not isinstance(additional_sections, list):
LOGGER.debug("additional_sections is not a list, converting")

additional_sections = [additional_sections]

for additional_section in additional_sections:
if not isinstance(additional_section, pymsteams.cardsection):
LOGGER.error(
"additional_section: %s is not an instance of %s",
additional_section,
pymsteams.cardsection,
)
continue

teams_card.addSection(additional_section)

return teams_card

def _dispatch(self, alert, descriptor):
"""Sends the Teams Card to Teams
Publishing:
By default the teams output sends a teams card comprising some default intro text
and a section containing:
* title with rule name
* alert description
* alert record (as a section of key/value pairs)
To override this behavior use the following fields:
- @teams.title (str):
Replaces the title of the teams connector card.
- @teams.description (str):
Replaces the text of the team connector card
- @teams.card_color (str):
Replaces the default color of the connector card (red)
Note: colors are represented by hex string
- @teams.with_record (bool):
Set to False, to remove the alert record section. Useful if you want to have a
more targeted approach for the alert
- @teams.additional_card_sections (list[pymsteams.cardsection]):
Pass in additional sections you want to send on the message.
@see cls._add_additional_sections() for more info
Args:
alert (Alert): Alert instance which triggered a rule
descriptor (str): Output descriptor
Returns:
bool: True if alert was sent successfully, False otherwise
"""
creds = self._load_creds(descriptor)
if not creds:
LOGGER.error("No credentials found for descriptor: %s", descriptor)
return False

# Create the publication
publication = compose_alert(alert, self, descriptor)

# Format the message
teams_card = self._format_message(alert, publication, creds["url"])

try:
teams_card.send()
except TeamsWebhookException as err:
LOGGER.error("Error Sending Alert to Teams: %s", err)
return False

return True
3 changes: 2 additions & 1 deletion streamalert_cli/manage_lambda/package.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ class LambdaPackage:
'netaddr': 'netaddr==0.7.19',
'policyuniverse': 'policyuniverse==1.3.2.1',
'requests': 'requests==2.22.0',
'pymsteams': 'pymsteams==0.1.12'
}

def __init__(self, config):
Expand Down Expand Up @@ -226,7 +227,7 @@ class AlertProcessorPackage(LambdaPackage):
'streamalert/shared'
}
package_name = 'alert_processor'
package_libs = {'cbapi', 'netaddr', 'requests'}
package_libs = {'cbapi', 'netaddr', 'pymsteams', 'requests'}


class AlertMergerPackage(LambdaPackage):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@ def test_output_loading():
'pagerduty-v2',
'pagerduty-incident',
'phantom',
'slack'
'slack',
'teams'
}
assert_count_equal(loaded_outputs, expected_outputs)

Expand Down
Loading

0 comments on commit b3638c4

Please sign in to comment.