diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4104e5566..358f9f314 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,6 +113,24 @@ jobs: # set a minimum confidence to ignore known false positives vulture --min-confidence 61 onefuzz + contrib-webhook-teams-service: + runs-on: ubuntu-18.04 + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-python@v2 + with: + python-version: 3.8 + - name: lint + shell: bash + run: | + set -ex + cd contrib/webhook-teams-service + python -m pip install --upgrade pip isort black mypy flake8 + pip install -r requirements.txt + mypy webhook + black webhook --check + isort --profile black webhook + flake8 webhook deploy-onefuzz-via-azure-devops: runs-on: ubuntu-18.04 steps: diff --git a/contrib/webhook-teams-service/.gitignore b/contrib/webhook-teams-service/.gitignore new file mode 100644 index 000000000..fbbe2efac --- /dev/null +++ b/contrib/webhook-teams-service/.gitignore @@ -0,0 +1,43 @@ +bin +obj +csx +.vs +edge +Publish + +*.user +*.suo +*.cscfg +*.Cache +project.lock.json + +/packages +/TestResults + +/tools/NuGet.exe +/App_Data +/secrets +/data +.secrets +appsettings.json +local.settings.json + +node_modules +dist + +# Local python packages +.python_packages/ + +# Python Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class \ No newline at end of file diff --git a/contrib/webhook-teams-service/README.md b/contrib/webhook-teams-service/README.md new file mode 100644 index 000000000..70df90b74 --- /dev/null +++ b/contrib/webhook-teams-service/README.md @@ -0,0 +1,85 @@ +# Webhooks Endpoint Example + +This example endpoint takes any incoming [OneFuzz webhook](../../docs/webhooks.md) and submits it to Microsoft Teams. + +Check out [Webhook Events Details](../../docs/webhook_events.md) for the schema of all supported events. + +## Creating an Azure Function + +1. Edit `local.settings.json` and add the following to the `Values` dictionary: + * Create a [Microsoft Teams incoming webhook URL](https://docs.microsoft.com/en-us/microsoftteams/platform/webhooks-and-connectors/how-to/connectors-using#setting-up-a-custom-incoming-webhook), and set this the value for `TEAMS_URL`. + * Create a random string that you generate, and set this value for `HMAC_TOKEN`. This will be used to [help secure your webhook](https://github.com/microsoft/onefuzz/blob/main/docs/webhooks.md#securing-your-webhook) +2. [Create Azure Resources for an Azure Function](https://docs.microsoft.com/en-us/azure/azure-functions/create-first-function-cli-python?tabs=azure-cli%2Cbash%2Cbrowser#5-create-supporting-azure-resources-for-your-function). +3. Ensure your function is HTTPS only: + ```bash + az functionapp update --resource-group --name --set httpsOnly=true + ``` +4. Deploy your function + ```bash + func azure functionapp publish --publish-local-settings + ``` +5. From the previous command, write down the URL for your webhook. It should look something like this: + ``` + webhook - [httpTrigger] + Invoke url: https://.azurewebsites.net/api/webhook?code= + ``` +6. Register this new URL webhook to OneFuzz. In this example, we're registering a webhook to tell our service any time a job is created and stopped: + ```bash + onefuzz webhooks create my-webhook "https://.azurewebsites.net/api/webhook?code=" job_created job_stopped --secret_token + ``` + + > NOTE: Make sure `HMAC_TOKEN` is the value we added to `local.settings.json` earlier. + + This will respond with something akin to: + ```json + { + "event_types": [ + "job_created", + "job_stopped" + ], + "name": "my-webhook", + "webhook_id": "9db7a8bb-0680-42a9-b336-655d3654fd6c" + } + ``` +7. Using the `webhook_id` we got in response, we can test our webhook using: + ```bash + onefuzz webhooks ping 9db7a8bb-0680-42a9-b336-655d3654fd6c + ``` +8. Using `webhook_id` we got in response, we can test if OneFuzz was able to send our service webhooks: + ``` + onefuzz webhooks logs 9db7a8bb-0680-42a9-b336-655d3654fd6c + ``` + + If our webhook is successful, we'll see something akin to: + ```json + [ + { + "event": { + "ping_id": "0770679d-67a0-4a6e-a5d7-751c7f80ebab" + }, + "event_id": "0c12ca77-bff8-4f8b-ae0d-f38f64cf0247", + "event_type": "ping", + "instance_id": "833bd437-775c-4b80-be62-599a9907f0f9", + "instance_name": "YOUR-ONEFUZZ-INSTANCE-NAME", + "state": "succeeded", + "try_count": 1, + "webhook_id": "9db7a8bb-0680-42a9-b336-655d3654fd6c" + } + ] + ``` + + OneFuzz will attempt to send each event up to 5 times before giving up. Instead of `succeeded` as above, you might see `retrying` if OneFuzz is still working to send the event, or `failed` if OneFuzz has given up sending the event. +9. Check your Teams channel. If all is successful, we should see something like the following: + ![Teams message Screenshot](example-message.png) + +## Troubleshooting + +* If your function isn't working as expected, check out the logs for your Azure Functions via: + ``` + func azure functionapp logstream --browser + ``` +* If you see exceptions that say `missing HMAC_TOKEN` or `missing TEAMS_URL`, you forgot to add those settings above. +* If you see exceptions saying `missing X-Onefuzz-Digest`, you forgot to set the `--secret_token` when you during `onefuzz webhooks create` above. This can be addressed via: + ``` + onefuzz webhooks update --secret_token + ``` diff --git a/contrib/webhook-teams-service/example-message.png b/contrib/webhook-teams-service/example-message.png new file mode 100755 index 000000000..c202b0bc3 Binary files /dev/null and b/contrib/webhook-teams-service/example-message.png differ diff --git a/contrib/webhook-teams-service/host.json b/contrib/webhook-teams-service/host.json new file mode 100644 index 000000000..05291ed43 --- /dev/null +++ b/contrib/webhook-teams-service/host.json @@ -0,0 +1,15 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[1.*, 2.0.0)" + } +} \ No newline at end of file diff --git a/contrib/webhook-teams-service/local.settings.json b/contrib/webhook-teams-service/local.settings.json new file mode 100644 index 000000000..daacb2832 --- /dev/null +++ b/contrib/webhook-teams-service/local.settings.json @@ -0,0 +1,6 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "python" + } +} diff --git a/contrib/webhook-teams-service/mypy.ini b/contrib/webhook-teams-service/mypy.ini new file mode 100644 index 000000000..b4a77a9d8 --- /dev/null +++ b/contrib/webhook-teams-service/mypy.ini @@ -0,0 +1,14 @@ +[mypy] +disallow_untyped_defs = True +follow_imports = silent +check_untyped_defs = True +disallow_any_generics = True +no_implicit_reexport = True +strict_optional = True +warn_redundant_casts = True +warn_return_any = True +warn_unused_configs = True +warn_unused_ignores = True + +[mypy-azure.*] +ignore_missing_imports = True diff --git a/contrib/webhook-teams-service/requirements.txt b/contrib/webhook-teams-service/requirements.txt new file mode 100644 index 000000000..4309123a6 --- /dev/null +++ b/contrib/webhook-teams-service/requirements.txt @@ -0,0 +1,4 @@ +# Do not include azure-functions-worker as it may conflict with the Azure Functions platform + +azure-functions +aiohttp \ No newline at end of file diff --git a/contrib/webhook-teams-service/webhook/__init__.py b/contrib/webhook-teams-service/webhook/__init__.py new file mode 100644 index 000000000..0dced4afe --- /dev/null +++ b/contrib/webhook-teams-service/webhook/__init__.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python +# +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. +# + + +import hmac +import json +import logging +import os +from hashlib import sha512 +from typing import Any, Dict + +import aiohttp +import azure.functions as func + + +def code_block(data: str) -> str: + data = data.replace("`", "``") + return "\n```\n%s\n```\n" % data + + +async def send_message(req: func.HttpRequest) -> bool: + data = req.get_json() + teams_url = os.environ.get("TEAMS_URL") + if teams_url is None: + raise Exception("missing TEAMS_URL") + + message: Dict[str, Any] = { + "@type": "MessageCard", + "@context": "https://schema.org/extensions", + "summary": data["instance_name"], + "sections": [ + { + "facts": [ + {"name": "instance", "value": data["instance_name"]}, + {"name": "event type", "value": data["event_type"]}, + ] + }, + {"text": code_block(json.dumps(data["event"], sort_keys=True))}, + ], + } + async with aiohttp.ClientSession() as client: + async with client.post(teams_url, json=message) as response: + return response.ok + + +def verify(req: func.HttpRequest) -> bool: + request_hmac = req.headers.get("X-Onefuzz-Digest") + if request_hmac is None: + raise Exception("missing X-Onefuzz-Digest") + + hmac_token = os.environ.get("HMAC_TOKEN") + if hmac_token is None: + raise Exception("missing HMAC_TOKEN") + + digest = hmac.new( + hmac_token.encode(), msg=req.get_body(), digestmod=sha512 + ).hexdigest() + if digest != request_hmac: + logging.error("invalid hmac") + return False + + return True + + +async def main(req: func.HttpRequest) -> func.HttpResponse: + if not verify(req): + return func.HttpResponse("no thanks") + + if await send_message(req): + return func.HttpResponse("unable to send message") + + return func.HttpResponse("thanks") diff --git a/contrib/webhook-teams-service/webhook/function.json b/contrib/webhook-teams-service/webhook/function.json new file mode 100644 index 000000000..ad312dfe1 --- /dev/null +++ b/contrib/webhook-teams-service/webhook/function.json @@ -0,0 +1,19 @@ +{ + "scriptFile": "__init__.py", + "bindings": [ + { + "authLevel": "function", + "type": "httpTrigger", + "direction": "in", + "name": "req", + "methods": [ + "post" + ] + }, + { + "type": "http", + "direction": "out", + "name": "$return" + } + ] +} \ No newline at end of file