diff --git a/.changeset/u11-external-signal-ingestion.md b/.changeset/u11-external-signal-ingestion.md new file mode 100644 index 0000000000..80872ce621 --- /dev/null +++ b/.changeset/u11-external-signal-ingestion.md @@ -0,0 +1,9 @@ +--- +"@runfusion/fusion": minor +--- + +Ingest external signals (Sentry / Datadog / PagerDuty / generic webhook) into triage tasks via a common `SignalSource` adapter seam (U11, KTD8). + +- New `POST /api/signals/:provider` endpoints, mirroring the GitHub ingestion path. Verified, normalized signals create a task in the `triage` column via the existing task store. +- Generic webhook is the must-work path; Sentry/Datadog/PagerDuty are thin adapters with provider-specific HMAC verification + payload normalization. Each normalized `Signal` carries a `groupingKey` (Sentry `issue.id`, PagerDuty `incident.id`, Datadog monitor key; the generic webhook requires a caller-supplied key or falls back to `source + normalized-title`) for the downstream storm guard. +- Security (mandatory): per-provider HMAC against an env-sourced secret (never source-controlled) with 401 on missing/invalid secret or signature — the generic webhook is never an unauthenticated task-creation endpoint; ±5 min replay window + delivery-id nonce dedup; persistent external-id dedup; ~1 MB body cap; per-source rate limit; field-length + meta-byte caps; SSRF-untrusted handling of payload URLs; `meta` stored as data, never rendered as raw HTML. diff --git a/packages/dashboard/README.md b/packages/dashboard/README.md index bb6b7e5431..070414a72c 100644 --- a/packages/dashboard/README.md +++ b/packages/dashboard/README.md @@ -770,6 +770,32 @@ For real-time PR/issue badge updates, configure a GitHub App instead of relying **Fallback Behavior:** When webhook delivery is unavailable, the 5-minute refresh endpoints (`/api/tasks/:id/pr/status`, `/api/tasks/:id/issue/status`) continue to work as the fallback path. Staleness is computed from persisted `lastCheckedAt` timestamps only (no in-memory poller state). +### External Signal Ingestion (Sentry / Datadog / PagerDuty / generic webhook) + +Inbound signals from error trackers and alerting tools are ingested into triage +tasks via `POST /api/signals/:provider`. Every endpoint requires a valid HMAC +signature against a per-provider secret — there is no unauthenticated +task-creation endpoint. Secrets come from the environment and are never +source-controlled: + +- `FUSION_SIGNAL_WEBHOOK_SECRET` — generic webhook (`POST /api/signals/webhook`). + Sign the raw body with HMAC-SHA256 in `X-Fusion-Signature` (hex, optional + `sha256=` prefix) and send `X-Fusion-Timestamp` (epoch ms) for the replay + window. Payload: `{ id, title, body?, severity?, link?, groupingKey?, timestamp?, meta? }`. + If `groupingKey` is omitted it falls back to `source + normalized-title`. +- `FUSION_SIGNAL_SENTRY_SECRET` — Sentry (`POST /api/signals/sentry`), verifies + `Sentry-Hook-Signature`; `groupingKey` = Sentry `issue.id`. +- `FUSION_SIGNAL_DATADOG_SECRET` — Datadog (`POST /api/signals/datadog`), + verifies `X-Datadog-Signature`; `groupingKey` = monitor `aggreg_key`/`alert_id`. +- `FUSION_SIGNAL_PAGERDUTY_SECRET` — PagerDuty (`POST /api/signals/pagerduty`), + verifies `X-PagerDuty-Signature` (`v1=`); `groupingKey` = `incident.id`. + +**Security:** mandatory HMAC (401 on missing/invalid secret or signature), +replay window (±5 min) + delivery-id nonce dedup, persistent external-id dedup, +~1 MB body cap (413), per-source rate limit (429), field-length caps on +normalized fields, and SSRF-untrusted handling of payload URLs (stored as data, +never fetched). The `meta` JSON is stored as data and never rendered as raw HTML. + ### Multi-Instance Deployments When running the dashboard on multiple instances behind a load balancer, badge updates can be shared across instances using Redis pub/sub. This ensures that a PR/issue badge change detected on instance A is delivered to subscribed WebSocket clients on instance B. diff --git a/packages/dashboard/src/__tests__/register-signal-routes.test.ts b/packages/dashboard/src/__tests__/register-signal-routes.test.ts new file mode 100644 index 0000000000..b4e8d86525 --- /dev/null +++ b/packages/dashboard/src/__tests__/register-signal-routes.test.ts @@ -0,0 +1,342 @@ +// @vitest-environment node + +import { createHmac } from "node:crypto"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import type { Task, TaskStore } from "@fusion/core"; +import { DeliveryNonceCache, type SignalSource } from "../signal-source.js"; +import { + ingestSignal, + resolveSignalSecret, + signalToTaskInput, + getSignalSource, +} from "../routes/register-signal-routes.js"; +import { webhookSource } from "../signal-sources/webhook.js"; +import { sentrySource } from "../signal-sources/sentry.js"; +import { datadogSource } from "../signal-sources/datadog.js"; +import { pagerdutySource } from "../signal-sources/pagerduty.js"; + +function sign(body: string, secret: string): string { + return createHmac("sha256", secret).update(Buffer.from(body)).digest("hex"); +} + +/** Minimal fake task store implementing only what the ingestion path uses. */ +function makeStore() { + const tasks: Task[] = []; + let counter = 0; + const store = { + async listTasks() { + return tasks; + }, + async createTask(input: Parameters[0]) { + const task = { + id: `FN-${++counter}`, + title: input.title, + description: input.description, + column: input.column, + source: input.source, + } as unknown as Task; + tasks.push(task); + return task; + }, + _tasks: tasks, + }; + return store as unknown as TaskStore & { _tasks: Task[] }; +} + +const SECRETS: Record = { + FUSION_SIGNAL_WEBHOOK_SECRET: "wh-secret", + FUSION_SIGNAL_SENTRY_SECRET: "sentry-secret", + FUSION_SIGNAL_DATADOG_SECRET: "datadog-secret", + FUSION_SIGNAL_PAGERDUTY_SECRET: "pd-secret", +}; + +const savedEnv: Record = {}; + +beforeEach(() => { + for (const [k, v] of Object.entries(SECRETS)) { + savedEnv[k] = process.env[k]; + process.env[k] = v; + } +}); + +afterEach(() => { + for (const k of Object.keys(SECRETS)) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } +}); + +function ctxFor(source: SignalSource, payload: object, headers: Record) { + const rawBody = Buffer.from(JSON.stringify(payload)); + const lower: Record = {}; + for (const [k, v] of Object.entries(headers)) lower[k.toLowerCase()] = v; + return { rawBody, headers: lower, body: payload }; +} + +describe("getSignalSource registry", () => { + it("resolves all four providers and rejects unknown", () => { + expect(getSignalSource("webhook")).toBe(webhookSource); + expect(getSignalSource("sentry")).toBe(sentrySource); + expect(getSignalSource("datadog")).toBe(datadogSource); + expect(getSignalSource("pagerduty")).toBe(pagerdutySource); + expect(getSignalSource("bogus")).toBeUndefined(); + }); +}); + +describe("ingestSignal — generic webhook (must-work path)", () => { + it("creates one triage task for a valid signed payload", async () => { + const store = makeStore(); + const ts = Date.now(); + const payload = { id: "evt-1", title: "Disk full", severity: "critical", link: "https://ops.example.com/a" }; + const { rawBody, headers, body } = ctxFor(webhookSource, payload, { + "x-fusion-signature": sign(JSON.stringify(payload), SECRETS.FUSION_SIGNAL_WEBHOOK_SECRET), + "x-fusion-timestamp": String(ts), + }); + + const res = await ingestSignal({ + source: webhookSource, + store, + rawBody, + headers, + body, + nonceCache: new DeliveryNonceCache(), + }); + + expect(res.status).toBe(201); + expect(res.taskId).toBe("FN-1"); + expect(store._tasks).toHaveLength(1); + expect(store._tasks[0].column).toBe("triage"); + const meta = store._tasks[0].source?.sourceMetadata as Record; + expect(meta.signalSource).toBe("webhook"); + expect(meta.signalDeliveryId).toBe("evt-1"); + expect(meta.signalGroupingKey).toBe("webhook:disk full"); + }); + + it("rejects with 401 and creates no task when no secret is configured", async () => { + delete process.env.FUSION_SIGNAL_WEBHOOK_SECRET; + const store = makeStore(); + const payload = { id: "x", title: "y" }; + const { rawBody, headers, body } = ctxFor(webhookSource, payload, { + "x-fusion-signature": "whatever", + "x-fusion-timestamp": String(Date.now()), + }); + const res = await ingestSignal({ + source: webhookSource, + store, + rawBody, + headers, + body, + nonceCache: new DeliveryNonceCache(), + }); + expect(res.status).toBe(401); + expect(store._tasks).toHaveLength(0); + }); + + it("rejects with 401 on an invalid signature", async () => { + const store = makeStore(); + const payload = { id: "x", title: "y" }; + const { rawBody, headers, body } = ctxFor(webhookSource, payload, { + "x-fusion-signature": sign("tampered", SECRETS.FUSION_SIGNAL_WEBHOOK_SECRET), + "x-fusion-timestamp": String(Date.now()), + }); + const res = await ingestSignal({ + source: webhookSource, + store, + rawBody, + headers, + body, + nonceCache: new DeliveryNonceCache(), + }); + expect(res.status).toBe(401); + expect(store._tasks).toHaveLength(0); + }); + + it("rejects a stale timestamp (replay window)", async () => { + const store = makeStore(); + const payload = { id: "x", title: "y" }; + const stale = Date.now() - 10 * 60_000; + const { rawBody, headers, body } = ctxFor(webhookSource, payload, { + "x-fusion-signature": sign(JSON.stringify(payload), SECRETS.FUSION_SIGNAL_WEBHOOK_SECRET), + "x-fusion-timestamp": String(stale), + }); + const res = await ingestSignal({ + source: webhookSource, + store, + rawBody, + headers, + body, + nonceCache: new DeliveryNonceCache(), + }); + expect(res.status).toBe(401); + expect(store._tasks).toHaveLength(0); + }); + + it("rejects a replayed delivery nonce", async () => { + const store = makeStore(); + const nonceCache = new DeliveryNonceCache(); + const payload = { id: "dup", title: "y" }; + const headersInput = { + "x-fusion-signature": sign(JSON.stringify(payload), SECRETS.FUSION_SIGNAL_WEBHOOK_SECRET), + "x-fusion-timestamp": String(Date.now()), + }; + const first = ctxFor(webhookSource, payload, headersInput); + const r1 = await ingestSignal({ source: webhookSource, store, ...first, nonceCache }); + expect(r1.status).toBe(201); + const second = ctxFor(webhookSource, payload, headersInput); + const r2 = await ingestSignal({ source: webhookSource, store, ...second, nonceCache }); + expect(r2.status).toBe(401); + expect(store._tasks).toHaveLength(1); + }); + + it("dedupes a duplicate external id against existing tasks (no double-create)", async () => { + const store = makeStore(); + const payload = { id: "same-id", title: "y" }; + const mk = () => + ctxFor(webhookSource, payload, { + "x-fusion-signature": sign(JSON.stringify(payload), SECRETS.FUSION_SIGNAL_WEBHOOK_SECRET), + "x-fusion-timestamp": String(Date.now()), + }); + // Two separate nonce caches simulate a process restart (nonce dedup reset), + // so the persistent external-id dedup is what must catch the duplicate. + const r1 = await ingestSignal({ source: webhookSource, store, ...mk(), nonceCache: new DeliveryNonceCache() }); + expect(r1.status).toBe(201); + const r2 = await ingestSignal({ source: webhookSource, store, ...mk(), nonceCache: new DeliveryNonceCache() }); + expect(r2.status).toBe(200); + expect(r2.deduped).toBe(true); + expect(r2.taskId).toBe("FN-1"); + expect(store._tasks).toHaveLength(1); + }); + + it("returns 400 with no task on a malformed payload", async () => { + const store = makeStore(); + const payload = { nope: true }; // missing id/title + const { rawBody, headers, body } = ctxFor(webhookSource, payload, { + "x-fusion-signature": sign(JSON.stringify(payload), SECRETS.FUSION_SIGNAL_WEBHOOK_SECRET), + "x-fusion-timestamp": String(Date.now()), + }); + const res = await ingestSignal({ + source: webhookSource, + store, + rawBody, + headers, + body, + nonceCache: new DeliveryNonceCache(), + }); + expect(res.status).toBe(400); + expect(store._tasks).toHaveLength(0); + }); +}); + +describe("ingestSignal — Sentry adapter", () => { + it("creates one triage task with normalized title/severity/link + groupingKey from issue.id", async () => { + const store = makeStore(); + const payload = { + data: { + issue: { + id: "1234", + title: "TypeError: undefined is not a function", + level: "fatal", + web_url: "https://sentry.io/issues/1234", + shortId: "WEB-12", + project: "web", + }, + }, + timestamp: Date.now(), + }; + const raw = JSON.stringify(payload); + const res = await ingestSignal({ + source: sentrySource, + store, + rawBody: Buffer.from(raw), + headers: { "sentry-hook-signature": sign(raw, SECRETS.FUSION_SIGNAL_SENTRY_SECRET) }, + body: payload, + nonceCache: new DeliveryNonceCache(), + }); + expect(res.status).toBe(201); + const task = store._tasks[0]; + const meta = task.source?.sourceMetadata as Record; + expect(meta.signalGroupingKey).toBe("1234"); + expect(meta.signalSeverity).toBe("critical"); + expect(task.title).toContain("TypeError"); + }); + + it("rejects an unsigned Sentry webhook with 401", async () => { + const store = makeStore(); + const payload = { data: { issue: { id: "1", title: "x" } } }; + const res = await ingestSignal({ + source: sentrySource, + store, + rawBody: Buffer.from(JSON.stringify(payload)), + headers: {}, + body: payload, + nonceCache: new DeliveryNonceCache(), + }); + expect(res.status).toBe(401); + expect(store._tasks).toHaveLength(0); + }); +}); + +describe("ingestSignal — Datadog & PagerDuty adapters (groupingKey from native primitive)", () => { + it("Datadog uses aggreg_key as groupingKey", async () => { + const store = makeStore(); + const payload = { aggreg_key: "agg-7", event_id: "ev-7", title: "High CPU", alert_type: "error" }; + const raw = JSON.stringify(payload); + const res = await ingestSignal({ + source: datadogSource, + store, + rawBody: Buffer.from(raw), + headers: { "x-datadog-signature": sign(raw, SECRETS.FUSION_SIGNAL_DATADOG_SECRET) }, + body: payload, + nonceCache: new DeliveryNonceCache(), + }); + expect(res.status).toBe(201); + const meta = store._tasks[0].source?.sourceMetadata as Record; + expect(meta.signalGroupingKey).toBe("agg-7"); + expect(meta.signalDeliveryId).toBe("ev-7"); + }); + + it("PagerDuty uses incident.id as groupingKey", async () => { + const store = makeStore(); + const payload = { + event: { + id: "evt-pd-1", + event_type: "incident.triggered", + occurred_at: new Date().toISOString(), + data: { id: "PINC1", title: "DB down", urgency: "high", html_url: "https://pd.example.com/i/PINC1", status: "triggered" }, + }, + }; + const raw = JSON.stringify(payload); + const res = await ingestSignal({ + source: pagerdutySource, + store, + rawBody: Buffer.from(raw), + headers: { "x-pagerduty-signature": `v1=${sign(raw, SECRETS.FUSION_SIGNAL_PAGERDUTY_SECRET)}` }, + body: payload, + nonceCache: new DeliveryNonceCache(), + }); + expect(res.status).toBe(201); + const meta = store._tasks[0].source?.sourceMetadata as Record; + expect(meta.signalGroupingKey).toBe("PINC1"); + expect(meta.signalDeliveryId).toBe("evt-pd-1"); + }); +}); + +describe("helpers", () => { + it("resolveSignalSecret reads the provider env var", () => { + expect(resolveSignalSecret(webhookSource)).toBe("wh-secret"); + expect(resolveSignalSecret(webhookSource, {})).toBeUndefined(); + }); + + it("signalToTaskInput maps to a triage task with provenance metadata", () => { + const input = signalToTaskInput({ + source: "webhook", + externalId: "e", + groupingKey: "g", + title: "t", + severity: "critical", + }); + expect(input.column).toBe("triage"); + expect(input.priority).toBe("high"); + expect(input.source?.sourceType).toBe("api"); + }); +}); diff --git a/packages/dashboard/src/__tests__/signal-source.test.ts b/packages/dashboard/src/__tests__/signal-source.test.ts new file mode 100644 index 0000000000..6a613f454c --- /dev/null +++ b/packages/dashboard/src/__tests__/signal-source.test.ts @@ -0,0 +1,124 @@ +// @vitest-environment node + +import { createHmac } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { + DeliveryNonceCache, + SignalRateLimiter, + applySignalCaps, + fallbackGroupingKey, + isSafeExternalUrl, + isWithinReplayWindow, + normalizeTitleForGrouping, + verifyHmacSignature, + type Signal, + SIGNAL_FIELD_CAPS, +} from "../signal-source.js"; + +function sign(body: string, secret: string): string { + return createHmac("sha256", secret).update(Buffer.from(body)).digest("hex"); +} + +describe("verifyHmacSignature", () => { + it("accepts a matching signature and rejects a wrong one", () => { + const body = Buffer.from(JSON.stringify({ a: 1 })); + const secret = "s3cr3t"; + const good = createHmac("sha256", secret).update(body).digest("hex"); + expect(verifyHmacSignature(body, good, secret)).toBe(true); + expect(verifyHmacSignature(body, good, "wrong")).toBe(false); + expect(verifyHmacSignature(body, undefined, secret)).toBe(false); + expect(verifyHmacSignature(body, "deadbeef", secret)).toBe(false); + }); +}); + +describe("isWithinReplayWindow", () => { + it("accepts recent timestamps and rejects stale or missing ones", () => { + const now = 1_000_000_000_000; + expect(isWithinReplayWindow(now, now)).toBe(true); + expect(isWithinReplayWindow(now - 4 * 60_000, now)).toBe(true); + expect(isWithinReplayWindow(now - 6 * 60_000, now)).toBe(false); + expect(isWithinReplayWindow(undefined, now)).toBe(false); + }); +}); + +describe("DeliveryNonceCache", () => { + it("rejects a replayed nonce within the window", () => { + const cache = new DeliveryNonceCache(1000); + expect(cache.check("a", 0)).toBe(true); + expect(cache.check("a", 500)).toBe(false); + // After TTL the nonce is evictable again. + expect(cache.check("a", 2000)).toBe(true); + }); +}); + +describe("SignalRateLimiter", () => { + it("caps a flood per source", () => { + const limiter = new SignalRateLimiter(1000, 3); + expect(limiter.allow("x", 0)).toBe(true); + expect(limiter.allow("x", 1)).toBe(true); + expect(limiter.allow("x", 2)).toBe(true); + expect(limiter.allow("x", 3)).toBe(false); + // A different source is independent. + expect(limiter.allow("y", 3)).toBe(true); + // After the window slides, capacity returns. + expect(limiter.allow("x", 2000)).toBe(true); + }); +}); + +describe("isSafeExternalUrl (SSRF guard)", () => { + it("rejects loopback, private, and non-http schemes; accepts public https", () => { + expect(isSafeExternalUrl("https://sentry.io/issues/1")).toBe(true); + expect(isSafeExternalUrl("http://example.com")).toBe(true); + expect(isSafeExternalUrl("https://localhost/x")).toBe(false); + expect(isSafeExternalUrl("http://127.0.0.1")).toBe(false); + expect(isSafeExternalUrl("http://10.0.0.5")).toBe(false); + expect(isSafeExternalUrl("http://192.168.1.1")).toBe(false); + expect(isSafeExternalUrl("http://169.254.169.254")).toBe(false); + expect(isSafeExternalUrl("file:///etc/passwd")).toBe(false); + expect(isSafeExternalUrl("javascript:alert(1)")).toBe(false); + expect(isSafeExternalUrl(undefined)).toBe(false); + }); +}); + +describe("grouping key fallback", () => { + it("derives source + normalized title", () => { + expect(normalizeTitleForGrouping(" Some ERROR ")).toBe("some error"); + expect(fallbackGroupingKey("webhook", "Disk Full!")).toBe("webhook:disk full!"); + }); +}); + +describe("applySignalCaps", () => { + it("truncates long fields and drops oversized meta + unsafe links", () => { + const signal: Signal = { + source: "webhook", + externalId: "e1", + groupingKey: "g1", + title: "x".repeat(SIGNAL_FIELD_CAPS.title + 50), + body: "y".repeat(SIGNAL_FIELD_CAPS.body + 50), + severity: "error", + link: "http://127.0.0.1/internal", + meta: { big: "z".repeat(SIGNAL_FIELD_CAPS.metaBytes + 100) }, + }; + const capped = applySignalCaps(signal); + expect(capped.title.length).toBe(SIGNAL_FIELD_CAPS.title); + expect(capped.body?.length).toBe(SIGNAL_FIELD_CAPS.body); + expect(capped.link).toBeUndefined(); // unsafe internal URL dropped + expect(capped.meta).toBeUndefined(); // oversized meta dropped + }); + + it("keeps a safe external link and small meta", () => { + const capped = applySignalCaps({ + source: "sentry", + externalId: "e1", + groupingKey: "g1", + title: "boom", + severity: "critical", + link: "https://sentry.io/issues/42", + meta: { project: "web" }, + }); + expect(capped.link).toBe("https://sentry.io/issues/42"); + expect(capped.meta).toEqual({ project: "web" }); + }); +}); + +export { sign }; diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index f5e9ca011c..7ccbae277b 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -168,6 +168,7 @@ import { registerProxyRoutes } from "./routes/register-proxy-routes.js"; import { registerModelRoutes } from "./routes/register-model-routes.js"; import { registerCustomProviderRoutes } from "./routes/register-custom-provider-routes.js"; import { registerUsageRoutes } from "./routes/register-usage-routes.js"; +import { registerSignalRoutes } from "./routes/register-signal-routes.js"; import { registerAuthRoutes } from "./routes/register-auth-routes.js"; import { registerRuntimeProviderRoutes } from "./routes/register-runtime-provider-routes.js"; import { registerFnBinaryRoutes } from "./routes/register-fn-binary-routes.js"; @@ -1989,6 +1990,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout }); registerUsageRoutes(routeContext); + // U11 — inbound external signal webhooks (Sentry/Datadog/PagerDuty/generic). + // Each route HMAC-verifies against a per-provider secret; never an + // unauthenticated task-creation endpoint. + registerSignalRoutes(routeContext); registerUpdateCheckRoutes(routeContext); registerDiagnosticsRoutes(routeContext); // CLI Agent Executor hook ingestion (U17) — per-session token auth, exempt from diff --git a/packages/dashboard/src/routes/register-signal-routes.ts b/packages/dashboard/src/routes/register-signal-routes.ts new file mode 100644 index 0000000000..ec2b7f6298 --- /dev/null +++ b/packages/dashboard/src/routes/register-signal-routes.ts @@ -0,0 +1,238 @@ +import type { Request, Response } from "express"; +import type { Task, TaskStore } from "@fusion/core"; +import { ApiError, badRequest, rateLimited, unauthorized } from "../api-error.js"; +import { + DeliveryNonceCache, + SIGNAL_MAX_BODY_BYTES, + SignalRateLimiter, + type Signal, + type SignalProvider, + type SignalSource, +} from "../signal-source.js"; +import { webhookSource } from "../signal-sources/webhook.js"; +import { sentrySource } from "../signal-sources/sentry.js"; +import { datadogSource } from "../signal-sources/datadog.js"; +import { pagerdutySource } from "../signal-sources/pagerduty.js"; +import type { ApiRouteRegistrar } from "./types.js"; + +/** + * U11 — inbound external-signal webhook routes. + * + * Mounts `POST /api/signals/:provider` for each supported provider. Each request + * is HMAC-verified by the provider adapter against a per-provider secret sourced + * from the environment (never source-controlled). Verified, normalized signals + * create a task in the `triage` column via the scoped task store, mirroring the + * GitHub ingestion path. + * + * Security applied here (mandatory, not deferred): + * - mandatory HMAC verify → 401 on missing/invalid secret or signature + * - body-size cap (~1 MB) → 413 + * - per-source rate limit → 429 + * - delivery-id nonce dedup (replay) → 401 + * - persistent external-id dedup against existing tasks → 200, no new task + * - field-length caps + meta-byte cap applied in the adapter (applySignalCaps) + * - URLs are SSRF-untrusted (stored as data only; unsafe links dropped) + * - `meta` stored as JSON data, never rendered as raw HTML + */ + +/** Thin registry — kept minimal per scope discipline (no heavy abstraction). */ +const SIGNAL_SOURCES: Record = { + webhook: webhookSource, + sentry: sentrySource, + datadog: datadogSource, + pagerduty: pagerdutySource, +}; + +export function getSignalSource(provider: string): SignalSource | undefined { + return SIGNAL_SOURCES[provider as SignalProvider]; +} + +/** + * Resolve a provider's HMAC secret. Env var is the canonical, never + * source-controlled source. An optional resolver (e.g. encrypted settings) can + * be supplied for deployments that store secrets there. + */ +export function resolveSignalSecret( + source: SignalSource, + env: NodeJS.ProcessEnv = process.env, +): string | undefined { + const value = env[source.secretEnvVar]; + return value && value.length > 0 ? value : undefined; +} + +const SIGNAL_DELIVERY_META_KEY = "signalDeliveryId"; +const SIGNAL_GROUPING_META_KEY = "signalGroupingKey"; +const SIGNAL_SOURCE_META_KEY = "signalSource"; + +/** + * Persistent delivery dedup: has a task already been created for this provider + + * external id? Scans recent tasks for the provenance marker. Mirrors the spirit + * of `github-tracking-dedup.ts` for the inbound path. + */ +async function findExistingSignalTask( + store: TaskStore, + provider: SignalProvider, + externalId: string, +): Promise { + const tasks = await store.listTasks({ slim: true, includeArchived: true }); + return tasks.find((t) => { + const meta = t.source?.sourceMetadata as Record | undefined; + return ( + meta?.[SIGNAL_SOURCE_META_KEY] === provider && + meta?.[SIGNAL_DELIVERY_META_KEY] === externalId + ); + }); +} + +/** Build a task-create input from a normalized signal. */ +export function signalToTaskInput(signal: Signal): Parameters[0] { + const lines: string[] = []; + if (signal.body) lines.push(signal.body); + if (signal.link) lines.push(`\nSource: ${signal.link}`); + lines.push(`\nSeverity: ${signal.severity}`); + const description = `${signal.title}\n\n${lines.join("\n")}`.trim(); + + return { + title: signal.title, + description, + column: "triage", + priority: signal.severity === "critical" ? "high" : undefined, + source: { + // Reuse the existing `api` source type — signals arrive over the API + // webhook surface. Provenance is carried in sourceMetadata so we do not + // need a core schema/type change for U11. + sourceType: "api", + sourceMetadata: { + [SIGNAL_SOURCE_META_KEY]: signal.source, + [SIGNAL_DELIVERY_META_KEY]: signal.externalId, + [SIGNAL_GROUPING_META_KEY]: signal.groupingKey, + signalSeverity: signal.severity, + signalLink: signal.link, + // `meta` is stored as data only and never rendered as raw HTML. + signalMeta: signal.meta, + }, + }, + }; +} + +/** + * Pure ingestion core: verify → dedup → normalize → create task. Exposed for + * unit testing without the full express app. + */ +export interface SignalIngestDeps { + source: SignalSource; + store: TaskStore; + rawBody: Buffer; + headers: Record; + body: unknown; + nonceCache: DeliveryNonceCache; +} + +export interface SignalIngestResult { + status: number; + taskId?: string; + deduped?: boolean; + error?: string; +} + +export async function ingestSignal(deps: SignalIngestDeps): Promise { + const { source, store, rawBody, headers, body, nonceCache } = deps; + const secret = resolveSignalSecret(source); + + // 1. Mandatory HMAC verification. Missing/invalid secret or signature → 401. + const verification = source.verify({ rawBody, headers, secret }); + if (!verification.valid) { + return { status: verification.status ?? 401, error: verification.error ?? "Unauthorized" }; + } + + // 2. Normalize (malformed payload → throw → caller maps to 4xx, no task). + let signal: Signal | null; + try { + signal = source.normalize(body, { rawBody, headers, secret }); + } catch (err) { + return { status: 400, error: err instanceof Error ? err.message : "Malformed payload" }; + } + if (!signal) { + // Valid-but-not-actionable (e.g. ping/health) → accepted, no task. + return { status: 200 }; + } + + // 3. Replay nonce dedup (same delivery id within the replay window) → 401. + if (!nonceCache.check(`${signal.source}:${signal.externalId}`)) { + return { status: 401, error: "Replayed delivery rejected" }; + } + + // 4. Persistent external-id dedup → 200 with the existing task, no new task. + const existing = await findExistingSignalTask(store, signal.source, signal.externalId); + if (existing) { + return { status: 200, taskId: existing.id, deduped: true }; + } + + // 5. Create the triage task. + const task = await store.createTask(signalToTaskInput(signal)); + return { status: 201, taskId: task.id }; +} + +export const registerSignalRoutes: ApiRouteRegistrar = (ctx) => { + const { router, getScopedStore } = ctx; + + // Shared per-process state for replay dedup + rate limiting. + const nonceCache = new DeliveryNonceCache(); + const rateLimiter = new SignalRateLimiter(); + + router.post("/signals/:provider", async (req: Request, res: Response) => { + const provider = Array.isArray(req.params.provider) + ? req.params.provider[0] + : req.params.provider; + + const source = getSignalSource(provider); + if (!source) { + throw badRequest(`Unknown signal provider: ${String(provider)}`); + } + + // Body-size cap (~1 MB) → 413. + const rawBody = (req as Request & { rawBody?: Buffer }).rawBody; + if (rawBody && rawBody.byteLength > SIGNAL_MAX_BODY_BYTES) { + throw new ApiError(413, "Signal payload too large"); + } + + // Per-source rate limit → 429. + if (!rateLimiter.allow(source.provider)) { + throw rateLimited(`Rate limit exceeded for signal source: ${source.provider}`); + } + + if (!rawBody) { + // Without the raw body we cannot HMAC-verify — never create a task. + throw unauthorized("Raw body not available for signature verification"); + } + + const headers: Record = {}; + for (const [key, value] of Object.entries(req.headers)) { + headers[key.toLowerCase()] = Array.isArray(value) ? value[0] : value; + } + + const store = await getScopedStore(req); + + const result = await ingestSignal({ + source, + store, + rawBody, + headers, + body: req.body, + nonceCache, + }); + + if (result.status === 401) { + throw unauthorized(result.error ?? "Unauthorized"); + } + if (result.status === 400) { + throw badRequest(result.error ?? "Malformed payload"); + } + + res.status(result.status).json({ + ok: result.status < 400, + taskId: result.taskId, + deduped: result.deduped ?? false, + }); + }); +}; diff --git a/packages/dashboard/src/signal-source.ts b/packages/dashboard/src/signal-source.ts new file mode 100644 index 0000000000..b0319f7d40 --- /dev/null +++ b/packages/dashboard/src/signal-source.ts @@ -0,0 +1,300 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +/** + * U11 — External signal ingestion seam. + * + * This module defines the common `SignalSource` adapter interface plus the + * shared security primitives (HMAC verification, replay window, nonce dedup, + * body-size cap, field-length caps, SSRF-untrusted URL handling) that every + * provider adapter reuses. It mirrors the GitHub ingestion path + * (`github-webhooks.ts`) which also lives in `packages/dashboard/src`. + * + * Scope discipline (per the plan): the generic webhook adapter is the + * must-work path. Sentry/Datadog/PagerDuty are thin adapters that supply + * provider-specific HMAC verification + payload normalization. We deliberately + * keep the registry thin — adapters are looked up by a small map, no heavy + * abstraction until more providers exist. + */ + +/** Normalized severity for an ingested signal. */ +export type SignalSeverity = "critical" | "error" | "warning" | "info"; + +/** Supported external signal providers. */ +export type SignalProvider = "sentry" | "datadog" | "pagerduty" | "webhook"; + +/** + * Field-length caps applied to every normalized {@link Signal} before it is + * turned into a task. External input is never trusted — caps bound storage and + * prevent abuse via oversized fields. + */ +export const SIGNAL_FIELD_CAPS = { + title: 300, + body: 8_000, + /** Cap on the serialized `meta` JSON (bytes). */ + metaBytes: 4_096, + groupingKey: 256, + link: 2_048, +} as const; + +/** Maximum accepted request body size for any signal webhook (bytes, ~1 MB). */ +export const SIGNAL_MAX_BODY_BYTES = 1_048_576; + +/** Replay window: reject signed payloads whose timestamp is outside ±5 min. */ +export const SIGNAL_REPLAY_WINDOW_MS = 5 * 60 * 1_000; + +/** + * A normalized external signal. Provider adapters map their native payloads + * onto this shape. Downstream (U13 storm guard) groups re-firing signals by + * {@link Signal.groupingKey}. + */ +export interface Signal { + /** Source provider that produced this signal. */ + source: SignalProvider; + /** Stable provider-specific external id (used for delivery dedup). */ + externalId: string; + /** + * Grouping primitive used by the storm guard to collapse re-firing signals + * (Sentry issue.id, PagerDuty incident.id, Datadog monitor key). The generic + * webhook requires the caller to supply one; otherwise it falls back to + * `source + normalized-title` (see {@link fallbackGroupingKey}). + */ + groupingKey: string; + /** Short human-visible title. */ + title: string; + /** Optional longer description / detail. */ + body?: string; + /** Normalized severity. */ + severity: SignalSeverity; + /** + * Optional canonical URL back to the source. Treated as SSRF-untrusted: it is + * stored as data and only rendered as an external link, never fetched server + * side. See {@link isSafeExternalUrl}. + */ + link?: string; + /** Provider event timestamp (epoch ms), used for the replay window. */ + timestamp?: number; + /** + * Non-rendered descriptor data carried from the source. Stored as JSON data + * only — never rendered as raw HTML in the dashboard. Capped to + * {@link SIGNAL_FIELD_CAPS.metaBytes}. + */ + meta?: Record; +} + +/** Context passed to an adapter's {@link SignalSource.verify}. */ +export interface SignalVerifyContext { + /** Raw request body bytes (required for HMAC). */ + rawBody: Buffer; + /** Lower-cased request headers. */ + headers: Record; + /** Per-provider secret resolved from env / encrypted settings. */ + secret: string | undefined; +} + +/** Result of an adapter's signature verification. */ +export interface SignalVerifyResult { + valid: boolean; + /** HTTP status to return on failure (always 401 for auth failures). */ + status?: number; + error?: string; +} + +/** + * A provider adapter. Kept intentionally thin: a mandatory `verify(ctx)` (HMAC) + * plus a `normalize(payload)` that yields a {@link Signal} (or `null` for a + * payload that is valid but not actionable, e.g. a ping/health event). + */ +export interface SignalSource { + readonly provider: SignalProvider; + /** + * The env var name carrying this provider's HMAC secret. Secrets are NEVER + * source-controlled; they come from the environment (or encrypted settings). + */ + readonly secretEnvVar: string; + /** Mandatory HMAC signature verification against a per-provider secret. */ + verify(ctx: SignalVerifyContext): SignalVerifyResult; + /** + * Normalize a parsed payload into a {@link Signal}. Throws (or returns null) + * for malformed/non-actionable payloads — callers translate a throw into a + * 4xx with no task created. + */ + normalize(payload: unknown, ctx: SignalVerifyContext): Signal | null; +} + +// ── Shared security helpers ──────────────────────────────────────────────── + +/** + * Constant-time comparison of a computed HMAC against a provided signature. + * `signatureHex` may carry a `sha256=` / `v1=` style prefix-stripped value. + */ +export function verifyHmacSignature( + rawBody: Buffer, + signatureHex: string | undefined, + secret: string, +): boolean { + if (!signatureHex) return false; + const expected = createHmac("sha256", secret).update(rawBody).digest("hex"); + if (signatureHex.length !== expected.length) return false; + try { + return timingSafeEqual(Buffer.from(signatureHex), Buffer.from(expected)); + } catch { + return false; + } +} + +/** True when the provider event timestamp is inside the replay window. */ +export function isWithinReplayWindow( + timestampMs: number | undefined, + nowMs: number = Date.now(), + windowMs: number = SIGNAL_REPLAY_WINDOW_MS, +): boolean { + if (timestampMs === undefined || !Number.isFinite(timestampMs)) { + // No timestamp → cannot bound replay; reject to stay safe. + return false; + } + return Math.abs(nowMs - timestampMs) <= windowMs; +} + +/** + * In-memory delivery-id nonce store with TTL eviction. Used to reject replayed + * deliveries (same external/delivery id) within the replay window. Mirrors the + * spirit of `github-tracking-dedup.ts` for the inbound path. + */ +export class DeliveryNonceCache { + private readonly seen = new Map(); + constructor(private readonly ttlMs: number = SIGNAL_REPLAY_WINDOW_MS) {} + + /** Returns true if this is a fresh delivery; false if a replay. */ + check(nonce: string, nowMs: number = Date.now()): boolean { + this.evict(nowMs); + if (this.seen.has(nonce)) return false; + this.seen.set(nonce, nowMs); + return true; + } + + private evict(nowMs: number): void { + for (const [key, ts] of this.seen) { + if (nowMs - ts > this.ttlMs) this.seen.delete(key); + } + } + + /** Test/diagnostic helper. */ + size(): number { + return this.seen.size; + } +} + +/** + * Per-source sliding-window rate limiter (in-memory). Caps a flood of inbound + * signals from a single provider. + */ +export class SignalRateLimiter { + private readonly hits = new Map(); + constructor( + private readonly windowMs: number = 60_000, + private readonly max: number = 120, + ) {} + + /** Returns true if the request is allowed; false if over the cap. */ + allow(key: string, nowMs: number = Date.now()): boolean { + const cutoff = nowMs - this.windowMs; + const arr = (this.hits.get(key) ?? []).filter((t) => t > cutoff); + if (arr.length >= this.max) { + this.hits.set(key, arr); + return false; + } + arr.push(nowMs); + this.hits.set(key, arr); + return true; + } +} + +/** + * SSRF guard for URLs found in payloads. We never fetch these URLs; this only + * gates whether a link is safe to store/surface as an external link. Rejects + * non-http(s) schemes and obvious internal/loopback/private hosts. + */ +export function isSafeExternalUrl(url: string | undefined): boolean { + if (!url) return false; + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return false; + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false; + const host = parsed.hostname.toLowerCase(); + if ( + host === "localhost" || + host === "0.0.0.0" || + host === "::1" || + host.endsWith(".localhost") || + host.endsWith(".internal") || + host.endsWith(".local") + ) { + return false; + } + // IPv4 private / loopback / link-local ranges. + const ipv4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (ipv4) { + const [a, b] = [Number(ipv4[1]), Number(ipv4[2])]; + if (a === 10) return false; + if (a === 127) return false; + if (a === 169 && b === 254) return false; + if (a === 172 && b >= 16 && b <= 31) return false; + if (a === 192 && b === 168) return false; + } + return true; +} + +/** Truncate a string to a cap, trimming whitespace. */ +function capString(value: string, cap: number): string { + const trimmed = value.trim(); + return trimmed.length > cap ? trimmed.slice(0, cap) : trimmed; +} + +/** Normalize a title for the fallback grouping key (lower-case, collapsed). */ +export function normalizeTitleForGrouping(title: string): string { + return title.trim().toLowerCase().replace(/\s+/g, " "); +} + +/** + * Generic-webhook grouping-key fallback: `source + normalized-title` when the + * caller does not supply an explicit grouping key. + */ +export function fallbackGroupingKey(source: SignalProvider, title: string): string { + return `${source}:${normalizeTitleForGrouping(title)}`; +} + +/** + * Apply field-length caps and meta-byte cap to a normalized signal. Drops a + * `link` that is not an SSRF-safe external URL (kept as data only otherwise via + * caller choice — here we drop unsafe links entirely). Returns a new object. + */ +export function applySignalCaps(signal: Signal): Signal { + let meta = signal.meta; + if (meta) { + let serialized = ""; + try { + serialized = JSON.stringify(meta); + } catch { + serialized = ""; + } + if (!serialized || Buffer.byteLength(serialized, "utf8") > SIGNAL_FIELD_CAPS.metaBytes) { + // Oversized or unserializable meta is dropped rather than truncated mid-JSON. + meta = undefined; + } + } + const link = + signal.link && isSafeExternalUrl(signal.link) + ? capString(signal.link, SIGNAL_FIELD_CAPS.link) + : undefined; + return { + ...signal, + title: capString(signal.title, SIGNAL_FIELD_CAPS.title) || "(untitled signal)", + body: signal.body ? capString(signal.body, SIGNAL_FIELD_CAPS.body) : undefined, + groupingKey: capString(signal.groupingKey, SIGNAL_FIELD_CAPS.groupingKey), + link, + meta, + }; +} diff --git a/packages/dashboard/src/signal-sources/datadog.ts b/packages/dashboard/src/signal-sources/datadog.ts new file mode 100644 index 0000000000..b87ed04986 --- /dev/null +++ b/packages/dashboard/src/signal-sources/datadog.ts @@ -0,0 +1,103 @@ +import { + applySignalCaps, + isWithinReplayWindow, + verifyHmacSignature, + type Signal, + type SignalSeverity, + type SignalSource, + type SignalVerifyContext, + type SignalVerifyResult, +} from "../signal-source.js"; + +/** + * Datadog adapter (scaffold). + * + * Datadog webhooks don't ship a built-in HMAC header, so the convention is to + * include a shared-secret HMAC the user templates into a custom header + * (`X-Datadog-Signature` = HMAC-SHA256(hex) of the raw body). `groupingKey` is + * the Datadog monitor/aggregation key (`alert_id` / `aggreg_key`). + */ + +function mapAlertType(value: unknown): SignalSeverity { + switch (value) { + case "error": + return "critical"; + case "warning": + case "warn": + return "warning"; + case "success": + case "recovery": + case "info": + return "info"; + default: + return "error"; + } +} + +export const datadogSource: SignalSource = { + provider: "datadog", + secretEnvVar: "FUSION_SIGNAL_DATADOG_SECRET", + + verify(ctx: SignalVerifyContext): SignalVerifyResult { + if (!ctx.secret) { + return { valid: false, status: 401, error: "Datadog signing secret is not configured" }; + } + const signature = ctx.headers["x-datadog-signature"]; + if (!signature) { + return { valid: false, status: 401, error: "Missing X-Datadog-Signature header" }; + } + if (!verifyHmacSignature(ctx.rawBody, signature, ctx.secret)) { + return { valid: false, status: 401, error: "Invalid signature" }; + } + const tsHeader = ctx.headers["x-datadog-timestamp"]; + if (tsHeader && !isWithinReplayWindow(Number(tsHeader))) { + return { valid: false, status: 401, error: "Timestamp outside replay window" }; + } + return { valid: true }; + }, + + normalize(payload: unknown): Signal | null { + if (!payload || typeof payload !== "object") { + throw new Error("Payload must be a JSON object"); + } + const p = payload as Record; + + const groupingKey = + (typeof p.aggreg_key === "string" && p.aggreg_key) || + (typeof p.alert_id === "string" && p.alert_id) || + (typeof p.id === "string" && p.id) || + ""; + if (!groupingKey) throw new Error("Missing Datadog aggreg_key/alert_id"); + + const externalId = + (typeof p.event_id === "string" && p.event_id) || + (typeof p.id === "string" && p.id) || + groupingKey; + + const title = + (typeof p.title === "string" && p.title) || + (typeof p.event_title === "string" && p.event_title) || + `Datadog alert ${groupingKey}`; + + const signal: Signal = { + source: "datadog", + externalId, + groupingKey, + title, + body: typeof p.body === "string" ? p.body : typeof p.text_only_msg === "string" ? p.text_only_msg : undefined, + severity: mapAlertType(p.alert_type), + link: typeof p.link === "string" ? p.link : typeof p.url === "string" ? p.url : undefined, + timestamp: + typeof p.date === "number" + ? p.date + : typeof p.last_updated === "number" + ? p.last_updated + : undefined, + meta: { + priority: typeof p.priority === "string" ? p.priority : undefined, + scope: typeof p.scope === "string" ? p.scope : undefined, + }, + }; + return applySignalCaps(signal); + }, +}; diff --git a/packages/dashboard/src/signal-sources/pagerduty.ts b/packages/dashboard/src/signal-sources/pagerduty.ts new file mode 100644 index 0000000000..3ac224cceb --- /dev/null +++ b/packages/dashboard/src/signal-sources/pagerduty.ts @@ -0,0 +1,95 @@ +import { + applySignalCaps, + isWithinReplayWindow, + verifyHmacSignature, + type Signal, + type SignalSeverity, + type SignalSource, + type SignalVerifyContext, + type SignalVerifyResult, +} from "../signal-source.js"; + +/** + * PagerDuty adapter (scaffold). + * + * PagerDuty v3 webhooks sign with `X-PagerDuty-Signature: v1=` = + * HMAC-SHA256 of the raw body using the subscription secret. `groupingKey` is + * the PagerDuty `incident.id` (native dedup primitive for U13's storm guard). + */ + +function mapUrgency(urgency: unknown, severity: unknown): SignalSeverity { + if (severity === "critical") return "critical"; + if (severity === "error") return "error"; + if (severity === "warning") return "warning"; + if (severity === "info") return "info"; + return urgency === "high" ? "critical" : "warning"; +} + +function parsePagerDutySignatureHeader(header: string | undefined): string | undefined { + if (!header) return undefined; + // Header may carry multiple comma-separated `v1=` signatures (key rotation). + for (const part of header.split(",")) { + const trimmed = part.trim(); + if (trimmed.startsWith("v1=")) return trimmed.slice("v1=".length); + } + return undefined; +} + +export const pagerdutySource: SignalSource = { + provider: "pagerduty", + secretEnvVar: "FUSION_SIGNAL_PAGERDUTY_SECRET", + + verify(ctx: SignalVerifyContext): SignalVerifyResult { + if (!ctx.secret) { + return { valid: false, status: 401, error: "PagerDuty signing secret is not configured" }; + } + const signature = parsePagerDutySignatureHeader(ctx.headers["x-pagerduty-signature"]); + if (!signature) { + return { valid: false, status: 401, error: "Missing X-PagerDuty-Signature header" }; + } + if (!verifyHmacSignature(ctx.rawBody, signature, ctx.secret)) { + return { valid: false, status: 401, error: "Invalid signature" }; + } + return { valid: true }; + }, + + normalize(payload: unknown): Signal | null { + if (!payload || typeof payload !== "object") { + throw new Error("Payload must be a JSON object"); + } + const p = payload as Record; + const event = (p.event as Record | undefined) ?? p; + const data = (event.data as Record | undefined) ?? event; + + const incidentId = + (typeof data.id === "string" && data.id) || + (typeof p.id === "string" && p.id) || + ""; + if (!incidentId) throw new Error("Missing PagerDuty incident.id"); + + const title = + (typeof data.title === "string" && data.title) || + (typeof data.summary === "string" && data.summary) || + `PagerDuty incident ${incidentId}`; + + const eventId = + typeof event.id === "string" ? event.id : incidentId; + + const signal: Signal = { + source: "pagerduty", + externalId: eventId, + groupingKey: incidentId, + title, + body: typeof data.description === "string" ? data.description : undefined, + severity: mapUrgency(data.urgency, data.severity), + link: typeof data.html_url === "string" ? data.html_url : undefined, + timestamp: + typeof event.occurred_at === "string" ? Date.parse(event.occurred_at) : undefined, + meta: { + eventType: typeof event.event_type === "string" ? event.event_type : undefined, + status: typeof data.status === "string" ? data.status : undefined, + }, + }; + return applySignalCaps(signal); + }, +}; diff --git a/packages/dashboard/src/signal-sources/sentry.ts b/packages/dashboard/src/signal-sources/sentry.ts new file mode 100644 index 0000000000..c2fb3c8015 --- /dev/null +++ b/packages/dashboard/src/signal-sources/sentry.ts @@ -0,0 +1,117 @@ +import { + applySignalCaps, + isWithinReplayWindow, + verifyHmacSignature, + type Signal, + type SignalSeverity, + type SignalSource, + type SignalVerifyContext, + type SignalVerifyResult, +} from "../signal-source.js"; + +/** + * Sentry adapter (scaffold). + * + * Sentry signs webhooks with `Sentry-Hook-Signature` = HMAC-SHA256(hex) of the + * raw request body using the integration's client secret. `groupingKey` is the + * Sentry `issue.id` (its native dedup primitive) — used by U13's storm guard. + */ + +function mapLevel(level: unknown): SignalSeverity { + switch (level) { + case "fatal": + case "critical": + return "critical"; + case "error": + return "error"; + case "warning": + return "warning"; + case "info": + case "debug": + return "info"; + default: + return "error"; + } +} + +export const sentrySource: SignalSource = { + provider: "sentry", + secretEnvVar: "FUSION_SIGNAL_SENTRY_SECRET", + + verify(ctx: SignalVerifyContext): SignalVerifyResult { + if (!ctx.secret) { + return { valid: false, status: 401, error: "Sentry signing secret is not configured" }; + } + const signature = ctx.headers["sentry-hook-signature"]; + if (!signature) { + return { valid: false, status: 401, error: "Missing Sentry-Hook-Signature header" }; + } + if (!verifyHmacSignature(ctx.rawBody, signature, ctx.secret)) { + return { valid: false, status: 401, error: "Invalid signature" }; + } + // Sentry sends `Sentry-Hook-Timestamp` (epoch ms) on installation events; + // when absent on issue events we fall back to the payload timestamp checked + // during normalize. Reject only when an explicit header is stale. + const tsHeader = ctx.headers["sentry-hook-timestamp"]; + if (tsHeader && !isWithinReplayWindow(Number(tsHeader))) { + return { valid: false, status: 401, error: "Timestamp outside replay window" }; + } + return { valid: true }; + }, + + normalize(payload: unknown): Signal | null { + if (!payload || typeof payload !== "object") { + throw new Error("Payload must be a JSON object"); + } + const p = payload as Record; + const data = (p.data as Record | undefined) ?? p; + const issue = + (data.issue as Record | undefined) ?? + (data.error as Record | undefined) ?? + (data.event as Record | undefined); + if (!issue || typeof issue !== "object") { + throw new Error("Missing Sentry issue/event data"); + } + + const issueId = + typeof issue.id === "string" + ? issue.id + : typeof issue.id === "number" + ? String(issue.id) + : ""; + if (!issueId) throw new Error("Missing Sentry issue.id"); + + const title = + (typeof issue.title === "string" && issue.title) || + (typeof issue.culprit === "string" && issue.culprit) || + `Sentry issue ${issueId}`; + + const link = + typeof issue.web_url === "string" + ? issue.web_url + : typeof issue.permalink === "string" + ? issue.permalink + : undefined; + + const signal: Signal = { + source: "sentry", + externalId: issueId, + groupingKey: issueId, + title, + body: typeof issue.culprit === "string" ? issue.culprit : undefined, + severity: mapLevel(issue.level), + link, + timestamp: + typeof p.timestamp === "number" + ? p.timestamp + : typeof issue.lastSeen === "string" + ? Date.parse(issue.lastSeen) + : undefined, + meta: { + project: typeof issue.project === "string" ? issue.project : undefined, + shortId: typeof issue.shortId === "string" ? issue.shortId : undefined, + }, + }; + return applySignalCaps(signal); + }, +}; diff --git a/packages/dashboard/src/signal-sources/webhook.ts b/packages/dashboard/src/signal-sources/webhook.ts new file mode 100644 index 0000000000..1bea7cdb79 --- /dev/null +++ b/packages/dashboard/src/signal-sources/webhook.ts @@ -0,0 +1,97 @@ +import { + applySignalCaps, + fallbackGroupingKey, + isWithinReplayWindow, + verifyHmacSignature, + type Signal, + type SignalSource, + type SignalVerifyContext, + type SignalVerifyResult, + type SignalSeverity, +} from "../signal-source.js"; + +/** + * Generic webhook adapter — the must-work path (per the plan's scope + * discipline). It is NEVER an unauthenticated task-creation endpoint: a missing + * or invalid secret/signature is rejected with 401. + * + * Signature: HMAC-SHA256 of the raw body, hex-encoded, in the + * `X-Fusion-Signature` header (optionally `sha256=`-prefixed). + * Timestamp: `X-Fusion-Timestamp` (epoch ms) drives the replay window. + * + * Payload contract (JSON): + * { + * "id": "", // required + * "title": "", // required + * "body"?: "...", + * "severity"?: "critical|error|warning|info", + * "link"?: "https://...", + * "groupingKey"?: "", // else falls back to source+title + * "timestamp"?: , + * "meta"?: { ... } + * } + */ + +const SEVERITIES: SignalSeverity[] = ["critical", "error", "warning", "info"]; + +function coerceSeverity(value: unknown): SignalSeverity { + return typeof value === "string" && (SEVERITIES as string[]).includes(value) + ? (value as SignalSeverity) + : "warning"; +} + +function stripSig(header: string | undefined): string | undefined { + if (!header) return undefined; + return header.startsWith("sha256=") ? header.slice("sha256=".length) : header; +} + +export const webhookSource: SignalSource = { + provider: "webhook", + secretEnvVar: "FUSION_SIGNAL_WEBHOOK_SECRET", + + verify(ctx: SignalVerifyContext): SignalVerifyResult { + if (!ctx.secret) { + return { valid: false, status: 401, error: "Webhook signing secret is not configured" }; + } + const signature = stripSig(ctx.headers["x-fusion-signature"]); + if (!signature) { + return { valid: false, status: 401, error: "Missing signature header" }; + } + if (!verifyHmacSignature(ctx.rawBody, signature, ctx.secret)) { + return { valid: false, status: 401, error: "Invalid signature" }; + } + const tsHeader = ctx.headers["x-fusion-timestamp"]; + const ts = tsHeader ? Number(tsHeader) : undefined; + if (!isWithinReplayWindow(ts)) { + return { valid: false, status: 401, error: "Timestamp outside replay window" }; + } + return { valid: true }; + }, + + normalize(payload: unknown): Signal | null { + if (!payload || typeof payload !== "object") { + throw new Error("Payload must be a JSON object"); + } + const p = payload as Record; + const externalId = typeof p.id === "string" ? p.id.trim() : ""; + const title = typeof p.title === "string" ? p.title.trim() : ""; + if (!externalId) throw new Error("Missing required field: id"); + if (!title) throw new Error("Missing required field: title"); + + const supplied = typeof p.groupingKey === "string" ? p.groupingKey.trim() : ""; + const groupingKey = supplied || fallbackGroupingKey("webhook", title); + + const signal: Signal = { + source: "webhook", + externalId, + groupingKey, + title, + body: typeof p.body === "string" ? p.body : undefined, + severity: coerceSeverity(p.severity), + link: typeof p.link === "string" ? p.link : undefined, + timestamp: typeof p.timestamp === "number" ? p.timestamp : undefined, + meta: p.meta && typeof p.meta === "object" ? (p.meta as Record) : undefined, + }; + return applySignalCaps(signal); + }, +};