Files
dd0c/products/03-alert-intelligence/src/ingestion/webhook.ts
Max Mayfield ccc4cd1c32 Scaffold dd0c/alert: ingestion, correlation engine, HMAC validation, tests
- Webhook ingestion: HMAC validation for Datadog/PagerDuty/OpsGenie with 5-min timestamp freshness
- Payload normalizers: canonical alert schema with severity mapping per provider
- Correlation engine: time-window grouping, late-alert attachment (2x window), FakeClock for testing
- InMemoryWindowStore for unit tests
- Tests: 12 HMAC validation cases, 5 normalizer cases, 7 correlation engine cases
- PostgreSQL schema with RLS: tenants, incidents, alerts, webhook_secrets, notification_configs
- Free tier enforcement columns (alert_count_month, reset_at)
- Fly.io config, Dockerfile, Gitea Actions CI
2026-03-01 02:49:14 +00:00

218 lines
6.4 KiB
TypeScript

import { z } from 'zod';
import crypto from 'node:crypto';
import pino from 'pino';
const logger = pino({ name: 'ingestion' });
// --- Canonical Alert Schema ---
export const canonicalAlertSchema = z.object({
sourceProvider: z.enum(['datadog', 'pagerduty', 'opsgenie', 'grafana', 'custom']),
sourceId: z.string(),
fingerprint: z.string(),
title: z.string(),
severity: z.enum(['critical', 'high', 'medium', 'low', 'info']),
status: z.enum(['firing', 'resolved']),
service: z.string().optional(),
environment: z.string().optional(),
tags: z.record(z.string()).default({}),
rawPayload: z.any(),
timestamp: z.number(), // Unix ms
});
export type CanonicalAlert = z.infer<typeof canonicalAlertSchema>;
// --- HMAC Validation (BMad Must-Have: Replay Prevention) ---
const MAX_TIMESTAMP_DRIFT_SECONDS = 300; // 5 minutes
export interface HmacValidationResult {
valid: boolean;
error?: string;
}
export function validateDatadogHmac(
body: string,
signature: string | undefined,
timestamp: string | undefined,
secret: string,
): HmacValidationResult {
if (!signature || !timestamp) {
return { valid: false, error: 'Missing signature or timestamp header' };
}
// Timestamp freshness check
const ts = parseInt(timestamp, 10);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - ts) > MAX_TIMESTAMP_DRIFT_SECONDS) {
return { valid: false, error: 'stale timestamp' };
}
const expected = crypto
.createHmac('sha256', secret)
.update(timestamp + body)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return { valid: false, error: 'Invalid signature' };
}
return { valid: true };
}
export function validatePagerdutyHmac(
body: string,
signature: string | undefined,
secret: string,
): HmacValidationResult {
if (!signature) {
return { valid: false, error: 'Missing signature header' };
}
// PagerDuty v1 signatures include timestamp in the signature header
const parts = signature.split(',');
const tsPart = parts.find(p => p.startsWith('t='));
const sigPart = parts.find(p => p.startsWith('v1='));
if (!tsPart || !sigPart) {
return { valid: false, error: 'Malformed PagerDuty signature' };
}
const ts = parseInt(tsPart.slice(2), 10);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - ts) > MAX_TIMESTAMP_DRIFT_SECONDS) {
return { valid: false, error: 'stale timestamp' };
}
const expected = crypto
.createHmac('sha256', secret)
.update(`${ts}.${body}`)
.digest('hex');
const sig = sigPart.slice(3);
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
return { valid: false, error: 'Invalid signature' };
}
return { valid: true };
}
export function validateOpsgenieHmac(
body: string,
signature: string | undefined,
secret: string,
): HmacValidationResult {
if (!signature) {
return { valid: false, error: 'Missing signature header' };
}
// OpsGenie: extract timestamp from payload body
let payload: any;
try {
payload = JSON.parse(body);
} catch {
return { valid: false, error: 'Invalid JSON body' };
}
const ts = payload?.timestamp;
if (ts) {
const tsSeconds = typeof ts === 'number' ? ts / 1000 : parseInt(ts, 10);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - tsSeconds) > MAX_TIMESTAMP_DRIFT_SECONDS) {
return { valid: false, error: 'stale timestamp' };
}
}
const expected = crypto
.createHmac('sha256', secret)
.update(body)
.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected))) {
return { valid: false, error: 'Invalid signature' };
}
return { valid: true };
}
// --- Payload Normalizers ---
export function normalizeDatadog(payload: any): CanonicalAlert {
return {
sourceProvider: 'datadog',
sourceId: payload.id ?? payload.alert_id ?? crypto.randomUUID(),
fingerprint: payload.aggregation_key ?? payload.alert_id ?? '',
title: payload.title ?? payload.msg_title ?? 'Datadog Alert',
severity: mapDatadogPriority(payload.priority),
status: payload.alert_transition === 'Recovered' ? 'resolved' : 'firing',
service: payload.tags?.service,
environment: payload.tags?.env,
tags: payload.tags ?? {},
rawPayload: payload,
timestamp: payload.date_happened ? payload.date_happened * 1000 : Date.now(),
};
}
export function normalizePagerduty(payload: any): CanonicalAlert {
const event = payload.event ?? payload;
const data = event.data ?? event.incident ?? {};
return {
sourceProvider: 'pagerduty',
sourceId: data.id ?? crypto.randomUUID(),
fingerprint: data.incident_key ?? data.id ?? '',
title: data.title ?? data.description ?? 'PagerDuty Incident',
severity: mapPagerdutyUrgency(data.urgency),
status: event.event_type?.includes('resolve') ? 'resolved' : 'firing',
service: data.service?.name,
environment: data.body?.details?.environment,
tags: {},
rawPayload: payload,
timestamp: data.created_at ? new Date(data.created_at).getTime() : Date.now(),
};
}
export function normalizeOpsgenie(payload: any): CanonicalAlert {
return {
sourceProvider: 'opsgenie',
sourceId: payload.alert?.alertId ?? crypto.randomUUID(),
fingerprint: payload.alert?.alias ?? payload.alert?.alertId ?? '',
title: payload.alert?.message ?? 'OpsGenie Alert',
severity: mapOpsgeniePriority(payload.alert?.priority),
status: payload.action === 'Close' ? 'resolved' : 'firing',
service: payload.alert?.tags?.find((t: string) => t.startsWith('service:'))?.slice(8),
tags: {},
rawPayload: payload,
timestamp: payload.alert?.createdAt ? new Date(payload.alert.createdAt).getTime() : Date.now(),
};
}
// --- Severity Mappers ---
function mapDatadogPriority(p: string | undefined): CanonicalAlert['severity'] {
switch (p) {
case 'P1': return 'critical';
case 'P2': return 'high';
case 'P3': return 'medium';
case 'P4': return 'low';
default: return 'medium';
}
}
function mapPagerdutyUrgency(u: string | undefined): CanonicalAlert['severity'] {
switch (u) {
case 'high': return 'critical';
case 'low': return 'low';
default: return 'medium';
}
}
function mapOpsgeniePriority(p: string | undefined): CanonicalAlert['severity'] {
switch (p) {
case 'P1': return 'critical';
case 'P2': return 'high';
case 'P3': return 'medium';
case 'P4': case 'P5': return 'low';
default: return 'medium';
}
}