|
| 1 | +import crypto from 'crypto' |
| 2 | +import { createLogger } from '@sim/logger' |
| 3 | +import { safeCompare } from '@sim/security/compare' |
| 4 | +import type { |
| 5 | + EventMatchContext, |
| 6 | + FormatInputContext, |
| 7 | + FormatInputResult, |
| 8 | + WebhookProviderHandler, |
| 9 | +} from '@/lib/webhooks/providers/types' |
| 10 | +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' |
| 11 | + |
| 12 | +const logger = createLogger('WebhookProvider:PagerDuty') |
| 13 | + |
| 14 | +/** |
| 15 | + * PagerDuty V3 signs the raw body with HMAC-SHA256 and sends it in the |
| 16 | + * `X-PagerDuty-Signature` header as one or more comma-separated `v1=<hex>` |
| 17 | + * values (multiple appear during signing-secret rotation). The delivery is |
| 18 | + * valid when our computed signature matches any of them. |
| 19 | + */ |
| 20 | +function validatePagerDutySignature(secret: string, signature: string, body: string): boolean { |
| 21 | + if (!secret || !signature || !body) return false |
| 22 | + const computed = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') |
| 23 | + return signature |
| 24 | + .split(',') |
| 25 | + .map((part) => part.trim()) |
| 26 | + .filter((part) => part.startsWith('v1=')) |
| 27 | + .some((part) => safeCompare(part.slice(3), computed)) |
| 28 | +} |
| 29 | + |
| 30 | +function asRecord(value: unknown): Record<string, unknown> { |
| 31 | + return (value as Record<string, unknown>) || {} |
| 32 | +} |
| 33 | + |
| 34 | +function referenceSummary( |
| 35 | + value: unknown |
| 36 | +): { id?: unknown; summary?: unknown; html_url?: unknown } | null { |
| 37 | + if (!value || typeof value !== 'object') return null |
| 38 | + const ref = value as Record<string, unknown> |
| 39 | + return { id: ref.id, summary: ref.summary, html_url: ref.html_url } |
| 40 | +} |
| 41 | + |
| 42 | +export const pagerdutyHandler: WebhookProviderHandler = { |
| 43 | + verifyAuth: createHmacVerifier({ |
| 44 | + configKey: 'webhookSecret', |
| 45 | + headerName: 'X-PagerDuty-Signature', |
| 46 | + validateFn: validatePagerDutySignature, |
| 47 | + providerLabel: 'PagerDuty', |
| 48 | + }), |
| 49 | + |
| 50 | + async matchEvent({ body, requestId, providerConfig }: EventMatchContext) { |
| 51 | + const triggerId = providerConfig.triggerId as string | undefined |
| 52 | + if (!triggerId || triggerId === 'pagerduty_webhook') return true |
| 53 | + |
| 54 | + const event = asRecord(asRecord(body).event) |
| 55 | + const eventType = event.event_type as string | undefined |
| 56 | + |
| 57 | + const { isPagerDutyEventMatch } = await import('@/triggers/pagerduty/utils') |
| 58 | + if (!isPagerDutyEventMatch(triggerId, eventType || '')) { |
| 59 | + logger.debug( |
| 60 | + `[${requestId}] PagerDuty event '${eventType}' does not match trigger ${triggerId}, skipping` |
| 61 | + ) |
| 62 | + return false |
| 63 | + } |
| 64 | + return true |
| 65 | + }, |
| 66 | + |
| 67 | + async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> { |
| 68 | + const event = asRecord(asRecord(body).event) |
| 69 | + const data = asRecord(event.data) |
| 70 | + const priority = referenceSummary(data.priority) |
| 71 | + |
| 72 | + return { |
| 73 | + input: { |
| 74 | + event_id: event.id, |
| 75 | + event_type: event.event_type, |
| 76 | + occurred_at: event.occurred_at, |
| 77 | + agent: event.agent ?? null, |
| 78 | + incident: { |
| 79 | + id: data.id, |
| 80 | + number: data.number, |
| 81 | + title: data.title, |
| 82 | + status: data.status, |
| 83 | + urgency: data.urgency, |
| 84 | + html_url: data.html_url, |
| 85 | + created_at: data.created_at, |
| 86 | + priority: priority?.summary ?? null, |
| 87 | + service: referenceSummary(data.service), |
| 88 | + escalation_policy: referenceSummary(data.escalation_policy), |
| 89 | + assignees: Array.isArray(data.assignees) ? data.assignees : [], |
| 90 | + }, |
| 91 | + }, |
| 92 | + } |
| 93 | + }, |
| 94 | + |
| 95 | + extractIdempotencyId(body: unknown) { |
| 96 | + const event = asRecord(asRecord(body).event) |
| 97 | + return (event.id as string | undefined) || null |
| 98 | + }, |
| 99 | +} |
0 commit comments