#!/usr/bin/env python3 """A complete Kesvara webhook receiver, in Flask. Copy this file, set KESVARA_SIGNING_KEY, run it, point your workspace's notification destination at http://your-host/kesvara. export KESVARA_SIGNING_KEY=... # from your Kesvara settings page pip install flask python flask_receiver.py This file is not an illustration. Kesvara's own test suite starts this exact program, sends it messages signed by the same code that signs yours, and then sends it a battery of forgeries and replays and asserts it refuses every one. If you change it, the part most worth re-testing is `verify` -- everything below it is ordinary web plumbing. The two mistakes this file exists to stop you making: 1. Verifying the signature and then handling the message anyway. A receiver that computes an HMAC and does not REJECT on mismatch has performed a calculation, not a security check. The `return` on the refusal path is the entire feature. 2. Signing a re-serialised copy of the body. `request.get_json()` gives you a dict; `json.dumps` of that dict is a DIFFERENT byte string from the one that arrived -- different spacing, and different escaping for any non-ASCII character, of which incident titles have plenty. The signature is over the bytes on the wire and nothing else, so this file reads `request.get_data()` and parses afterwards. """ from __future__ import annotations import hashlib import hmac import json import os import time from flask import Flask, request # Read from the environment, never pasted into the file. This is the one value # that lets anyone forge a message your receiver will believe. SECRET = os.environ["KESVARA_SIGNING_KEY"] SIG_HEADER = "X-Kesvara-Signature" TS_HEADER = "X-Kesvara-Timestamp" VERSION = "v1" # How much clock skew you will tolerate, in seconds. Kesvara cannot enforce # this for you -- it is your end of the exchange. Five minutes is a normal # choice: long enough to survive an unsynchronised clock, short enough that a # captured message is not replayable tomorrow. TOLERANCE_SECONDS = 300 def verify(secret: str, ts_header: str, sig_header: str, body: bytes, now: int | None = None) -> str | None: """Return None if the message is genuine, or a reason to refuse it. Returning a reason rather than a bool because the reason belongs in your log. "Refused a webhook" at 4am with no reason is a support ticket. """ if not sig_header or not ts_header: return "missing signature headers" # An explicit version check. If Kesvara ever signs a different shape of # string it will say v2, and a receiver written against v1 must refuse it # rather than compute a v1 signature over it and quietly disagree. if not sig_header.startswith(VERSION + "="): return "unknown signature version" if not ts_header.lstrip("-").isdigit(): return "malformed timestamp" # The timestamp is signed as the exact string that was sent, not as a # number that has been through int() and back. Those differ for " 123" and # for "0123", and a signature scheme with two spellings of the same input # is a signature scheme with a hole in it. signed = VERSION.encode() + b":" + ts_header.encode() + b":" + body expected = VERSION + "=" + hmac.new( secret.encode(), signed, hashlib.sha256).hexdigest() # compare_digest, not ==. A byte-by-byte comparison that returns early # leaks the correct signature to anyone who can time it. if not hmac.compare_digest(expected, sig_header): return "signature mismatch" # Freshness is checked AFTER the signature, deliberately. Before it, you # would be making a decision about a header that nothing has authenticated # yet. After it, the timestamp is known to be the one Kesvara signed -- # which is what makes rejecting an old one a real defence against a # replayed message rather than a formality. age = (int(time.time()) if now is None else now) - int(ts_header) if age > TOLERANCE_SECONDS: return "timestamp too old" if age < -TOLERANCE_SECONDS: return "timestamp too far in the future" return None app = Flask(__name__) @app.post("/kesvara") def kesvara(): body = request.get_data() # RAW bytes. Not request.get_json(). why = verify(SECRET, request.headers.get(TS_HEADER, ""), request.headers.get(SIG_HEADER, ""), body) if why is not None: # Log the reason, never the key and never the expected signature -- # printing what you expected hands a forger the answer. app.logger.warning("refused a Kesvara webhook: %s", why) return {"refused": why}, 401 message = json.loads(body) if message.get("event") == "incident.opened": incident = message["incident"] # Kesvara retries a failed delivery, and a retry re-sends the SAME # bytes with the same timestamp and the same signature. If this handler # does something you would not want done twice -- paging someone, # opening a ticket -- key it on incident["id"], which is stable across # retries. Do not key it on the signature, which is stable too and will # therefore also suppress a genuine second delivery after a rotation. handle_incident(incident) return {"ok": True}, 200 def handle_incident(incident: dict) -> None: """Yours to write. This is what the payload contains.""" print(f"incident {incident['id']}: {incident['title']} " f"({incident['alert_count']} alerts)") similar = incident.get("similar") if similar: # Kesvara found an older incident of yours that looks like this one. # `action_item` is a sentence one of your team typed at the time. # It is not a diagnosis and Kesvara does not claim it is one. print(f" similar to {similar['date']}: {similar['action_item']}") else: print(" no similar resolved incident in this workspace's history") if __name__ == "__main__": app.run(host="127.0.0.1", port=int(os.environ.get("PORT", "8080")))