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
218 lines
7.4 KiB
TypeScript
218 lines
7.4 KiB
TypeScript
import { createLogger } from '@sim/logger'
|
|
import { safeCompare } from '@sim/security/compare'
|
|
import { hmacSha256Base64 } from '@sim/security/hmac'
|
|
import { NextResponse } from 'next/server'
|
|
import type {
|
|
AuthContext,
|
|
EventMatchContext,
|
|
FormatInputContext,
|
|
FormatInputResult,
|
|
WebhookProviderHandler,
|
|
} from '@/lib/webhooks/providers/types'
|
|
|
|
const logger = createLogger('WebhookProvider:Incidentio')
|
|
|
|
const INCIDENTIO_WEBHOOK_TIMESTAMP_SKEW_SECONDS = 5 * 60
|
|
|
|
/**
|
|
* Verify an incident.io webhook signature using the Svix signing scheme.
|
|
* incident.io webhooks are powered by Svix: HMAC-SHA256 of
|
|
* `${webhook-id}.${webhook-timestamp}.${body}` signed with the base64-decoded
|
|
* `whsec_...` secret, compared against the `webhook-signature` header which may
|
|
* carry one or more space-separated `v1,<base64sig>` entries.
|
|
* @see https://docs.incident.io/integrations/webhooks
|
|
*/
|
|
function verifyIncidentioSignature(
|
|
secret: string,
|
|
msgId: string,
|
|
timestamp: string,
|
|
signatures: string,
|
|
rawBody: string
|
|
): boolean {
|
|
try {
|
|
const ts = Number.parseInt(timestamp, 10)
|
|
const now = Math.floor(Date.now() / 1000)
|
|
if (Number.isNaN(ts) || Math.abs(now - ts) > INCIDENTIO_WEBHOOK_TIMESTAMP_SKEW_SECONDS) {
|
|
return false
|
|
}
|
|
|
|
const secretBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64')
|
|
const toSign = `${msgId}.${timestamp}.${rawBody}`
|
|
const expectedSignature = hmacSha256Base64(toSign, secretBytes)
|
|
|
|
const providedSignatures = signatures.split(' ')
|
|
for (const versionedSig of providedSignatures) {
|
|
const parts = versionedSig.split(',')
|
|
if (parts.length !== 2) continue
|
|
const sig = parts[1]
|
|
if (safeCompare(sig, expectedSignature)) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
} catch (error) {
|
|
logger.error('Error verifying incident.io Svix signature:', error)
|
|
return false
|
|
}
|
|
}
|
|
|
|
function asObject(value: unknown): Record<string, unknown> | null {
|
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
return value as Record<string, unknown>
|
|
}
|
|
return null
|
|
}
|
|
|
|
function asString(value: unknown): string | null {
|
|
return typeof value === 'string' ? value : null
|
|
}
|
|
|
|
/**
|
|
* Locate a named entity (incident/alert) within an incident.io webhook body.
|
|
*
|
|
* Verified against the incident.io webhooks OpenAPI spec
|
|
* (https://docs.incident.io/openapi/webhooks.json). The body is a Svix
|
|
* envelope `{ event_type, [event_type]: <wrapper> }` where:
|
|
* - `incident_created_v2` / `incident_updated_v2` / `alert_created_v1`: the
|
|
* wrapper IS the entity (incident/alert fields directly under the key).
|
|
* - `incident_status_updated_v2`: the wrapper nests the incident under
|
|
* `.incident` alongside `new_status` / `previous_status` / `message`.
|
|
* Returns null when the entity cannot be found.
|
|
*/
|
|
function extractEntity(
|
|
body: Record<string, unknown>,
|
|
eventType: string,
|
|
key: 'incident' | 'alert'
|
|
): Record<string, unknown> | null {
|
|
const wrapper = eventType ? asObject(body[eventType]) : null
|
|
if (!wrapper) return null
|
|
return asObject(wrapper[key]) ?? wrapper
|
|
}
|
|
|
|
export const incidentioHandler: WebhookProviderHandler = {
|
|
async verifyAuth({
|
|
request,
|
|
rawBody,
|
|
requestId,
|
|
providerConfig,
|
|
}: AuthContext): Promise<NextResponse | null> {
|
|
const signingSecret = providerConfig.signingSecret as string | undefined
|
|
if (!signingSecret?.trim()) {
|
|
logger.warn(
|
|
`[${requestId}] incident.io webhook missing signing secret in provider configuration`
|
|
)
|
|
return new NextResponse('Unauthorized - incident.io signing secret is required', {
|
|
status: 401,
|
|
})
|
|
}
|
|
|
|
const webhookId = request.headers.get('webhook-id')
|
|
const webhookTimestamp = request.headers.get('webhook-timestamp')
|
|
const webhookSignature = request.headers.get('webhook-signature')
|
|
|
|
if (!webhookId || !webhookTimestamp || !webhookSignature) {
|
|
logger.warn(`[${requestId}] incident.io webhook missing Svix signature headers`)
|
|
return new NextResponse('Unauthorized - Missing incident.io signature headers', {
|
|
status: 401,
|
|
})
|
|
}
|
|
|
|
if (
|
|
!verifyIncidentioSignature(
|
|
signingSecret,
|
|
webhookId,
|
|
webhookTimestamp,
|
|
webhookSignature,
|
|
rawBody
|
|
)
|
|
) {
|
|
logger.warn(`[${requestId}] incident.io Svix signature verification failed`)
|
|
return new NextResponse('Unauthorized - Invalid incident.io signature', { status: 401 })
|
|
}
|
|
|
|
return null
|
|
},
|
|
|
|
async matchEvent({ body, providerConfig, requestId }: EventMatchContext) {
|
|
const triggerId = providerConfig.triggerId as string | undefined
|
|
if (!triggerId) {
|
|
return true
|
|
}
|
|
|
|
const { isIncidentioEventMatch } = await import('@/triggers/incidentio/utils')
|
|
const eventType = (body as Record<string, unknown>)?.event_type as string | undefined
|
|
|
|
if (!isIncidentioEventMatch(triggerId, eventType || '')) {
|
|
logger.debug(
|
|
`[${requestId}] incident.io event mismatch for trigger ${triggerId}. event_type: ${eventType}. Skipping.`
|
|
)
|
|
return false
|
|
}
|
|
return true
|
|
},
|
|
|
|
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
|
|
const b = (asObject(body) ?? {}) as Record<string, unknown>
|
|
const eventType = typeof b.event_type === 'string' ? b.event_type : ''
|
|
const wrapper = eventType ? asObject(b[eventType]) : null
|
|
const isAlert = eventType.startsWith('public_alert.')
|
|
|
|
if (isAlert) {
|
|
const alert = extractEntity(b, eventType, 'alert')
|
|
return {
|
|
input: {
|
|
event_type: eventType,
|
|
alert,
|
|
alert_id: asString(alert?.id),
|
|
title: asString(alert?.title),
|
|
description: asString(alert?.description),
|
|
status: asString(alert?.status),
|
|
alert_source_id: asString(alert?.alert_source_id),
|
|
deduplication_key: asString(alert?.deduplication_key),
|
|
source_url: asString(alert?.source_url),
|
|
created_at: asString(alert?.created_at),
|
|
updated_at: asString(alert?.updated_at),
|
|
resolved_at: asString(alert?.resolved_at),
|
|
payload: b,
|
|
},
|
|
}
|
|
}
|
|
|
|
const incident = extractEntity(b, eventType, 'incident')
|
|
return {
|
|
input: {
|
|
event_type: eventType,
|
|
incident,
|
|
incident_id: asString(incident?.id),
|
|
name: asString(incident?.name),
|
|
reference: asString(incident?.reference),
|
|
summary: asString(incident?.summary),
|
|
incident_status: asObject(incident?.incident_status),
|
|
severity: asObject(incident?.severity),
|
|
mode: asString(incident?.mode),
|
|
visibility: asString(incident?.visibility),
|
|
permalink: asString(incident?.permalink),
|
|
created_at: asString(incident?.created_at),
|
|
updated_at: asString(incident?.updated_at),
|
|
new_status: asObject(wrapper?.new_status),
|
|
previous_status: asObject(wrapper?.previous_status),
|
|
update_message: asString(wrapper?.message),
|
|
payload: b,
|
|
},
|
|
}
|
|
},
|
|
|
|
extractIdempotencyId(body: unknown) {
|
|
const b = asObject(body)
|
|
if (!b) return null
|
|
const eventType = typeof b.event_type === 'string' ? b.event_type : ''
|
|
const key = eventType.startsWith('public_alert.') ? 'alert' : 'incident'
|
|
const entity = extractEntity(b, eventType, key as 'incident' | 'alert')
|
|
const entityId = entity && typeof entity.id === 'string' ? entity.id : null
|
|
if (eventType && entityId) {
|
|
return `${eventType}:${entityId}`
|
|
}
|
|
return null
|
|
},
|
|
}
|