#!/usr/bin/env node /** * A complete Sentinel webhook receiver, in Express. * * Copy this file, set SENTINEL_SIGNING_KEY, run it, point your workspace's * notification destination at http://your-host/sentinel. * * export SENTINEL_SIGNING_KEY=... # from your Sentinel settings page * npm install express * node express_receiver.js * * This file is not an illustration. Sentinel'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 plumbing. * * The mistake this file exists to stop you making is `express.json()`. * * The signature is over the bytes on the wire. `express.json()` consumes those * bytes and hands you a parsed object, and `JSON.stringify` of that object is * a DIFFERENT byte string: Node writes {"a":1} where the sender wrote * {"a": 1}. Every signature check against it fails, for every message, and the * obvious way out of a check that fails on genuine traffic is to stop * checking. So this route uses express.raw() and parses afterwards. * * If something else in your app has already installed express.json() globally, * the raw bytes are gone by the time your handler runs. Either mount this * route before that middleware, or capture the body as it arrives: * * app.use(express.json({ * verify: (req, res, buf) => { req.rawBody = buf }, * })) */ const crypto = require("node:crypto"); const express = require("express"); // Read from the environment, never pasted into the file. This is the one value // that lets anyone forge a message your receiver will believe. const SECRET = process.env.SENTINEL_SIGNING_KEY; if (!SECRET) throw new Error("SENTINEL_SIGNING_KEY is not set"); const SIG_HEADER = "x-sentinel-signature"; const TS_HEADER = "x-sentinel-timestamp"; const VERSION = "v1"; // How much clock skew you will tolerate, in seconds. Sentinel 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. const TOLERANCE_SECONDS = 300; /** * Returns null if the message is genuine, or a reason to refuse it. * The reason is for your log; "refused a webhook" with no reason is a support * ticket at 4am. * * @param {string} secret * @param {string} tsHeader * @param {string} sigHeader * @param {Buffer} body the RAW request body, not a re-serialised object * @param {number} [now] unix seconds; defaults to the clock */ function verify(secret, tsHeader, sigHeader, body, now) { if (!sigHeader || !tsHeader) return "missing signature headers"; // An explicit version check. If Sentinel 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 (!sigHeader.startsWith(VERSION + "=")) return "unknown signature version"; if (!/^-?[0-9]+$/.test(tsHeader)) return "malformed timestamp"; // The timestamp is signed as the exact string that was sent, not as a number // that has been through Number() and back. Those differ for "0123", and a // signature scheme with two spellings of the same input has a hole in it. const signed = Buffer.concat([ Buffer.from(`${VERSION}:${tsHeader}:`, "utf8"), body, ]); const expected = VERSION + "=" + crypto.createHmac("sha256", secret).update(signed).digest("hex"); // timingSafeEqual, not ===. A comparison that returns early leaks the // correct signature to anyone who can measure it. It throws on a length // mismatch, so the lengths are compared first -- that comparison reveals // only the length, which is fixed and public anyway. const a = Buffer.from(expected, "utf8"); const b = Buffer.from(sigHeader, "utf8"); if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) { return "signature mismatch"; } // Freshness is checked AFTER the signature, deliberately. Before it, you // would be making a decision about a header nothing has authenticated yet. // After it, the timestamp is known to be the one Sentinel signed -- which is // what makes rejecting an old one a real defence against a replayed message // rather than a formality. const clock = now === undefined ? Math.floor(Date.now() / 1000) : now; const age = clock - Number(tsHeader); if (age > TOLERANCE_SECONDS) return "timestamp too old"; if (age < -TOLERANCE_SECONDS) return "timestamp too far in the future"; return null; } const app = express(); app.post("/sentinel", express.raw({ type: "*/*" }), (req, res) => { const body = req.body; // a Buffer of the RAW bytes, because of express.raw const why = verify( SECRET, req.get(TS_HEADER) || "", req.get(SIG_HEADER) || "", body, ); if (why !== null) { // Log the reason, never the key and never the expected signature -- // printing what you expected hands a forger the answer. console.warn(`refused a Sentinel webhook: ${why}`); return res.status(401).json({ refused: why }); } const message = JSON.parse(body.toString("utf8")); if (message.event === "incident.opened") { // Sentinel 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. handleIncident(message.incident); } return res.status(200).json({ ok: true }); }); /** Yours to write. This is what the payload contains. */ function handleIncident(incident) { console.log( `incident ${incident.id}: ${incident.title} ` + `(${incident.alert_count} alerts)`, ); if (incident.similar) { // Sentinel 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 Sentinel does not claim it is one. console.log( ` similar to ${incident.similar.date}: ${incident.similar.action_item}`, ); } else { console.log(" no similar resolved incident in this workspace's history"); } } // Exported so a test can call `verify` directly. The server only starts when // this file is run, not when it is required. module.exports = { verify, app }; if (require.main === module) { const port = Number(process.env.PORT || 8080); app.listen(port, "127.0.0.1", () => { console.log(`listening on 127.0.0.1:${port}`); }); }