Files
simstudioai--sim/apps/sim/lib/webhooks/providers/pagerduty.ts
T
wehub-resource-sync d25d482dc2
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:20:55 +08:00

227 lines
8.5 KiB
TypeScript

import crypto from 'crypto'
import { createLogger } from '@sim/logger'
import { safeCompare } from '@sim/security/compare'
import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
DeleteSubscriptionContext,
EventMatchContext,
FormatInputContext,
FormatInputResult,
SubscriptionContext,
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { createHmacVerifier } from '@/lib/webhooks/providers/utils'
const logger = createLogger('WebhookProvider:PagerDuty')
const PAGERDUTY_API_BASE = 'https://api.pagerduty.com'
/** Shared headers for PagerDuty REST API calls (the v2 Accept header is required). */
function pagerdutyHeaders(apiKey: string): Record<string, string> {
return {
Authorization: `Token token=${apiKey}`,
'Content-Type': 'application/json',
Accept: 'application/vnd.pagerduty+json;version=2',
}
}
/**
* PagerDuty V3 signs the raw body with HMAC-SHA256 and sends it in the
* `X-PagerDuty-Signature` header as one or more comma-separated `v1=<hex>`
* values (multiple appear during signing-secret rotation). The delivery is
* valid when our computed signature matches any of them.
*/
function validatePagerDutySignature(secret: string, signature: string, body: string): boolean {
if (!secret || !signature || !body) return false
const computed = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex')
return signature
.split(',')
.map((part) => part.trim())
.filter((part) => part.startsWith('v1='))
.some((part) => safeCompare(part.slice(3), computed))
}
function asRecord(value: unknown): Record<string, unknown> {
return (value as Record<string, unknown>) || {}
}
/**
* Best-effort cleanup of a webhook subscription after a failed setup. Deletes by
* id when known, otherwise finds the subscription pointing at `url` and deletes
* it, so a created subscription is never orphaned in PagerDuty.
*/
async function cleanupPagerDutySubscription(
apiKey: string,
url: string,
subscriptionId?: string
): Promise<void> {
let id = subscriptionId
if (!id) {
const listRes = await fetch(`${PAGERDUTY_API_BASE}/webhook_subscriptions`, {
headers: pagerdutyHeaders(apiKey),
}).catch(() => null)
if (!listRes || !listRes.ok) return
const body = (await listRes.json().catch(() => null)) as {
webhook_subscriptions?: Array<{ id?: string; delivery_method?: { url?: string } }>
} | null
id = body?.webhook_subscriptions?.find((sub) => sub.delivery_method?.url === url)?.id
}
if (!id) return
await fetch(`${PAGERDUTY_API_BASE}/webhook_subscriptions/${id}`, {
method: 'DELETE',
headers: pagerdutyHeaders(apiKey),
}).catch(() => null)
}
function referenceSummary(
value: unknown
): { id?: unknown; summary?: unknown; html_url?: unknown } | null {
if (!value || typeof value !== 'object') return null
const ref = value as Record<string, unknown>
return { id: ref.id, summary: ref.summary, html_url: ref.html_url }
}
export const pagerdutyHandler: WebhookProviderHandler = {
verifyAuth: createHmacVerifier({
configKey: 'webhookSecret',
headerName: 'X-PagerDuty-Signature',
validateFn: validatePagerDutySignature,
providerLabel: 'PagerDuty',
// The signing secret is captured during auto-registration, so a missing
// secret means misconfiguration — fail closed rather than skip verification.
requireSecret: true,
}),
async matchEvent({ body, requestId, providerConfig }: EventMatchContext) {
const triggerId = providerConfig.triggerId as string | undefined
if (!triggerId || triggerId === 'pagerduty_webhook') return true
const event = asRecord(asRecord(body).event)
const eventType = event.event_type as string | undefined
const { isPagerDutyEventMatch } = await import('@/triggers/pagerduty/utils')
if (!isPagerDutyEventMatch(triggerId, eventType || '')) {
logger.debug(
`[${requestId}] PagerDuty event '${eventType}' does not match trigger ${triggerId}, skipping`
)
return false
}
return true
},
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const event = asRecord(asRecord(body).event)
const data = asRecord(event.data)
const priority = referenceSummary(data.priority)
return {
input: {
event_id: event.id,
event_type: event.event_type,
occurred_at: event.occurred_at,
agent: event.agent ?? null,
incident: {
id: data.id,
number: data.number,
title: data.title,
status: data.status,
urgency: data.urgency,
html_url: data.html_url,
created_at: data.created_at,
priority: priority?.summary ?? null,
service: referenceSummary(data.service),
escalation_policy: referenceSummary(data.escalation_policy),
assignees: Array.isArray(data.assignees) ? data.assignees : [],
},
},
}
},
extractIdempotencyId(body: unknown) {
const event = asRecord(asRecord(body).event)
return (event.id as string | undefined) || null
},
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
const config = getProviderConfig(ctx.webhook)
const apiKey = config.apiKey as string | undefined
const triggerId = config.triggerId as string | undefined
if (!apiKey)
throw new Error('PagerDuty API Key is required to create the webhook subscription.')
const { getPagerDutyEvents } = await import('@/triggers/pagerduty/utils')
const res = await fetch(`${PAGERDUTY_API_BASE}/webhook_subscriptions`, {
method: 'POST',
headers: pagerdutyHeaders(apiKey),
body: JSON.stringify({
webhook_subscription: {
type: 'webhook_subscription',
delivery_method: { type: 'http_delivery_method', url: getNotificationUrl(ctx.webhook) },
events: getPagerDutyEvents(triggerId ?? 'pagerduty_webhook'),
filter: { type: 'account_reference' },
},
}),
})
if (!res.ok) {
const detail = await res.text().catch(() => '')
logger.error(`[${ctx.requestId}] Failed to create PagerDuty webhook (${res.status})`, {
detail,
})
if (res.status === 401)
throw new Error('PagerDuty authentication failed. Verify your REST API key.')
if (res.status === 403)
throw new Error('PagerDuty access denied. The API key must have read/write access.')
throw new Error(`Failed to create PagerDuty webhook subscription: ${res.status}`)
}
const created = asRecord((await res.json().catch(() => ({}))) as unknown)
const subscription = asRecord(created.webhook_subscription)
const externalId = subscription.id as string | undefined
const secret = asRecord(subscription.delivery_method).secret as string | undefined
// The subscription exists once PagerDuty returns success; if it is missing
// its id or signing secret, delete it so it is not orphaned, then fail.
if (!externalId || !secret) {
await cleanupPagerDutySubscription(apiKey, getNotificationUrl(ctx.webhook), externalId)
if (!externalId) {
throw new Error('PagerDuty webhook created but no subscription ID was returned.')
}
throw new Error('PagerDuty webhook created but no signing secret was returned on creation.')
}
logger.info(`[${ctx.requestId}] Created PagerDuty webhook subscription ${externalId}`)
return { providerConfigUpdates: { externalId, webhookSecret: secret } }
},
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
const config = getProviderConfig(ctx.webhook)
const apiKey = config.apiKey as string | undefined
const externalId = config.externalId as string | undefined
if (!apiKey || !externalId) {
if (ctx.strict) throw new Error('Missing PagerDuty API key or subscription ID for deletion.')
logger.warn(
`[${ctx.requestId}] Skipping PagerDuty webhook cleanup — missing API key or subscription ID`
)
return
}
const res = await fetch(`${PAGERDUTY_API_BASE}/webhook_subscriptions/${externalId}`, {
method: 'DELETE',
headers: pagerdutyHeaders(apiKey),
})
if (!res.ok && res.status !== 404) {
if (ctx.strict) throw new Error(`Failed to delete PagerDuty webhook: ${res.status}`)
logger.warn(
`[${ctx.requestId}] Failed to delete PagerDuty webhook ${externalId} (non-fatal): ${res.status}`
)
return
}
logger.info(`[${ctx.requestId}] Deleted PagerDuty webhook subscription ${externalId}`)
},
}