From 2af58d092cf7fa9c566316e0559e5bc25a4a9476 Mon Sep 17 00:00:00 2001 From: checktheroads Date: Fri, 26 Jun 2020 12:23:11 -0700 Subject: [PATCH] Add Microsoft Teams webhook support --- docs/docs/logging.mdx | 12 ++- hyperglass/api/models/query.py | 25 ++++-- hyperglass/api/routes.py | 9 ++- hyperglass/configuration/models/logging.py | 2 +- hyperglass/external/msteams.py | 26 ++++++ hyperglass/external/webhooks.py | 4 +- hyperglass/models.py | 93 ++++++++++++++++++---- hyperglass/util.py | 3 - 8 files changed, 136 insertions(+), 38 deletions(-) create mode 100644 hyperglass/external/msteams.py diff --git a/docs/docs/logging.mdx b/docs/docs/logging.mdx index e550db7..61e8e71 100644 --- a/docs/docs/logging.mdx +++ b/docs/docs/logging.mdx @@ -57,10 +57,11 @@ If http logging is enabled, an HTTP POST will be sent to the configured target e ### Supported Providers -| Provider | Parameter Value | -| :-------------------------- | --------------: | -| [Slack](https://slack.com/) | `'slack'` | -| Generic | `'generic'` | +| Provider | Parameter Value | +| :--------------------------------------------------------------------------------------------------- | --------------: | +| Generic | `'generic'` | +| [Microsoft Teams](https://www.microsoft.com/en-us/microsoft-365/microsoft-teams/group-chat-software) | `'msteams'` | +| [Slack](https://slack.com/) | `'slack'` | ### Authentication @@ -87,10 +88,7 @@ If the `provider` field is set to `'generic'`, the webhook will POST JSON data i "query_vrf": "default", "query_target": "1.1.1.0/24", "headers": { - "content-length": "103", - "accept": "application/json, text/plain, */*", "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.163 Safari/537.36", - "content-type": "application/json;charset=UTF-8", "referer": "http://lg.example.com/", "accept-encoding": "gzip, deflate, br", "accept-language": "en-US,en;q=0.9,fr;q=0.8,lb;q=0.7,la;q=0.6" diff --git a/hyperglass/api/models/query.py b/hyperglass/api/models/query.py index c7d9ba3..ea1f5a1 100644 --- a/hyperglass/api/models/query.py +++ b/hyperglass/api/models/query.py @@ -126,14 +126,25 @@ class Query(BaseModel): """Get this query's device object by query_location.""" return getattr(devices, self.query_location) - def export_dict(self): + def export_dict(self, pretty=False): """Create dictionary representation of instance.""" - return { - "query_location": self.query_location, - "query_type": self.query_type, - "query_vrf": self.query_vrf.name, - "query_target": str(self.query_target), - } + if pretty: + loc = getattr(devices, self.query_location) + query_type = getattr(params.queries, self.query_type) + items = { + "query_location": loc.display_name, + "query_type": query_type.display_name, + "query_vrf": self.query_vrf.display_name, + "query_target": str(self.query_target), + } + else: + items = { + "query_location": self.query_location, + "query_type": self.query_type, + "query_vrf": self.query_vrf.name, + "query_target": str(self.query_target), + } + return items def export_json(self): """Create JSON representation of instance.""" diff --git a/hyperglass/api/routes.py b/hyperglass/api/routes.py index 3940f91..b6ae015 100644 --- a/hyperglass/api/routes.py +++ b/hyperglass/api/routes.py @@ -4,6 +4,7 @@ import os import json import time +from datetime import datetime # Third Party from fastapi import HTTPException, BackgroundTasks @@ -25,7 +26,7 @@ from hyperglass.api.models.cert_import import EncodedRequest APP_PATH = os.environ["hyperglass_directory"] -async def send_webhook(query_data: Query, request: Request) -> int: +async def send_webhook(query_data: Query, request: Request, timestamp: datetime) -> int: """If webhooks are enabled, get request info and send a webhook. Args: @@ -47,10 +48,11 @@ async def send_webhook(query_data: Query, request: Request) -> int: async with Webhook(params.logging.http) as hook: await hook.send( query={ - **query_data.export_dict(), + **query_data.export_dict(pretty=True), "headers": headers, "source": request.client.host, "network": network_info, + "timestamp": timestamp, } ) @@ -58,7 +60,8 @@ async def send_webhook(query_data: Query, request: Request) -> int: async def query(query_data: Query, request: Request, background_tasks: BackgroundTasks): """Ingest request data pass it to the backend application to perform the query.""" - background_tasks.add_task(send_webhook, query_data, request) + timestamp = datetime.utcnow() + background_tasks.add_task(send_webhook, query_data, request, timestamp) # Initialize cache cache = Cache(db=params.cache.database, **REDIS_CONFIG) diff --git a/hyperglass/configuration/models/logging.py b/hyperglass/configuration/models/logging.py index 0894c43..e993d12 100644 --- a/hyperglass/configuration/models/logging.py +++ b/hyperglass/configuration/models/logging.py @@ -53,7 +53,7 @@ class Http(HyperglassModelExtra): """HTTP logging parameters.""" enable: StrictBool = True - provider: constr(regex=r"(slack|generic)") = "generic" + provider: constr(regex=r"(msteams|slack|generic)") = "generic" host: AnyHttpUrl authentication: Optional[HttpAuth] headers: Dict[StrictStr, Union[StrictStr, StrictInt, StrictBool, None]] = {} diff --git a/hyperglass/external/msteams.py b/hyperglass/external/msteams.py new file mode 100644 index 0000000..b5f705d --- /dev/null +++ b/hyperglass/external/msteams.py @@ -0,0 +1,26 @@ +"""Session handler for Microsoft Teams API.""" + +# Project +from hyperglass.log import log +from hyperglass.models import Webhook +from hyperglass.external._base import BaseExternal + + +class MSTeams(BaseExternal, name="MSTeams"): + """Microsoft Teams session handler.""" + + def __init__(self, config): + """Initialize external base class with Microsoft Teams connection details.""" + + super().__init__( + base_url="https://outlook.office.com", config=config, parse=False + ) + + async def send(self, query): + """Send an incoming webhook to Microsoft Teams.""" + + payload = Webhook(**query) + + log.debug("Sending query data to Microsoft Teams:\n{}", payload) + + return await self._apost(endpoint=self.config.host.path, data=payload.msteams()) diff --git a/hyperglass/external/webhooks.py b/hyperglass/external/webhooks.py index 2fc88e8..8348522 100644 --- a/hyperglass/external/webhooks.py +++ b/hyperglass/external/webhooks.py @@ -5,10 +5,12 @@ from hyperglass.exceptions import HyperglassError from hyperglass.external._base import BaseExternal from hyperglass.external.slack import SlackHook from hyperglass.external.generic import GenericHook +from hyperglass.external.msteams import MSTeams PROVIDER_MAP = { - "slack": SlackHook, "generic": GenericHook, + "msteams": MSTeams, + "slack": SlackHook, } diff --git a/hyperglass/models.py b/hyperglass/models.py index 50086b0..594f6bb 100644 --- a/hyperglass/models.py +++ b/hyperglass/models.py @@ -3,6 +3,7 @@ # Standard Library import re from typing import TypeVar, Optional +from datetime import datetime # Third Party from pydantic import HttpUrl, BaseModel, StrictInt, StrictStr, StrictFloat @@ -13,6 +14,9 @@ from hyperglass.util import clean_name IntFloat = TypeVar("IntFloat", StrictInt, StrictFloat) +_WEBHOOK_TITLE = "hyperglass received a valid query with the following data" +_ICON_URL = "https://res.cloudinary.com/hyperglass/image/upload/v1593192484/icon.png" + class HyperglassModel(BaseModel): """Base model for all hyperglass configuration models.""" @@ -160,10 +164,7 @@ class StrictBytes(bytes): class WebhookHeaders(HyperglassModel): """Webhook data model.""" - content_length: Optional[StrictStr] - accept: Optional[StrictStr] user_agent: Optional[StrictStr] - content_type: Optional[StrictStr] referer: Optional[StrictStr] accept_encoding: Optional[StrictStr] accept_language: Optional[StrictStr] @@ -174,9 +175,7 @@ class WebhookHeaders(HyperglassModel): """Pydantic model config.""" fields = { - "content_length": "content-length", "user_agent": "user-agent", - "content_type": "content-type", "accept_encoding": "accept-encoding", "accept_language": "accept-language", "x_real_ip": "x-real-ip", @@ -201,6 +200,66 @@ class Webhook(HyperglassModel): headers: WebhookHeaders source: StrictStr network: WebhookNetwork + timestamp: datetime + + def msteams(self): + """Format the webhook data as a Microsoft Teams card.""" + + def code(value): + """Wrap argument in backticks for markdown inline code formatting.""" + return f"`{str(value)}`" + + try: + + header_data = [ + {"name": k, "value": code(v)} + for k, v in self.headers.dict(by_alias=True).items() + ] + + time_fmt = self.timestamp.strftime("%Y %m %d %H:%M:%S") + payload = { + "@type": "MessageCard", + "@context": "http://schema.org/extensions", + "themeColor": "118ab2", + "summary": _WEBHOOK_TITLE, + "sections": [ + { + "activityTitle": _WEBHOOK_TITLE, + "activitySubtitle": f"{time_fmt} UTC", + "activityImage": _ICON_URL, + "facts": [ + {"name": "Query Location", "value": self.query_location}, + {"name": "Query Target", "value": code(self.query_target)}, + {"name": "Query Type", "value": self.query_type}, + {"name": "Query VRF", "value": self.query_vrf}, + ], + }, + {"markdown": True, "text": "**Source Information**"}, + {"markdown": True, "text": "---"}, + { + "markdown": True, + "facts": [ + {"name": "Source IP", "value": code(self.source)}, + { + "name": "Source Prefix", + "value": code(self.network.prefix), + }, + {"name": "Source ASN", "value": code(self.network.asn)}, + ], + }, + {"markdown": True, "text": "**Request Headers**"}, + {"markdown": True, "text": "---"}, + {"markdown": True, "facts": header_data}, + ], + } + + log.debug("Created MS Teams webhook: {}", str(payload)) + + except Exception as err: + log.error("Error while creating webhook: {}", str(err)) + payload = {} + + return payload def slack(self): """Format the webhook data as a Slack message.""" @@ -216,16 +275,18 @@ class Webhook(HyperglassModel): field = make_field(k, v, code=True) header_data.append(field) - query_details = ( - ("Query Location", self.query_location), - ("Query Type", self.query_type), - ("Query VRF", self.query_vrf), - ("Query Target", self.query_target), - ) - query_data = [] - for k, v in query_details: - field = make_field(k, v) - query_data.append({"type": "mrkdwn", "text": field}) + query_data = [ + { + "type": "mrkdwn", + "text": make_field("Query Location", self.query_location), + }, + { + "type": "mrkdwn", + "text": make_field("Query Target", self.query_target, code=True), + }, + {"type": "mrkdwn", "text": make_field("Query Type", self.query_type)}, + {"type": "mrkdwn", "text": make_field("Query VRF", self.query_vrf)}, + ] source_details = ( ("Source IP", self.source), @@ -239,7 +300,7 @@ class Webhook(HyperglassModel): source_data.append({"type": "mrkdwn", "text": field}) payload = { - "text": "hyperglass received a valid query with the following data", + "text": _WEBHOOK_TITLE, "blocks": [ {"type": "section", "fields": query_data}, {"type": "divider"}, diff --git a/hyperglass/util.py b/hyperglass/util.py index 2d0d0cb..40c9b71 100644 --- a/hyperglass/util.py +++ b/hyperglass/util.py @@ -824,10 +824,7 @@ async def process_headers(headers): """Filter out unwanted headers and return as a dictionary.""" headers = dict(headers) header_keys = ( - "content-length", - "accept", "user-agent", - "content-type", "referer", "accept-encoding", "accept-language",