Security findings from April 30 review were claimed fixed but never committed. Applied all remediations: HIGH: - WebhookHandler: fail fast when DARKWATCH_WEBHOOK_SECRET missing instead of defaulting to hardcoded secret - field-encryption.service: require PII_ENCRYPTION_KEY at startup instead of defaulting MEDIUM: - WebhookHandler: make signature required (was optional, accepted unsigned events) - WebhookHandler: reject unknown event types instead of silently defaulting to SCAN_TRIGGER - scheduler.routes + webhook.routes: add ownership checks on /:userId endpoints (IDOR) LOW: - webhook.routes: generic error responses, full error logged server-side Co-Authored-By: Paperclip <noreply@paperclip.ing>
205 lines
5.3 KiB
TypeScript
205 lines
5.3 KiB
TypeScript
import prisma from "@shieldai/db";
|
|
import { createHmac, timingSafeEqual } from "crypto";
|
|
import { DataSource, WebhookEventType } from "@shieldai/types";
|
|
|
|
export class WebhookHandler {
|
|
private secret: string;
|
|
|
|
constructor(secret?: string) {
|
|
if (secret) {
|
|
this.secret = secret;
|
|
} else if (process.env.DARKWATCH_WEBHOOK_SECRET) {
|
|
this.secret = process.env.DARKWATCH_WEBHOOK_SECRET;
|
|
} else {
|
|
console.warn("[Webhook] DARKWATCH_WEBHOOK_SECRET not set — signature verification will fail");
|
|
this.secret = "";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Verify HMAC signature of incoming webhook payload.
|
|
*/
|
|
verifySignature(payload: string, signature: string | string[]): boolean {
|
|
if (!this.secret) return false;
|
|
if (!signature) return false;
|
|
|
|
const sigArray = Array.isArray(signature) ? signature : [signature];
|
|
const expected = this.computeSignature(payload);
|
|
|
|
for (const sig of sigArray) {
|
|
if (timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Process an incoming webhook event.
|
|
* Validates, stores, and triggers appropriate action.
|
|
*/
|
|
async processEvent(
|
|
eventType: string,
|
|
payload: Record<string, unknown>,
|
|
source?: string,
|
|
signature?: string
|
|
): Promise<{ eventId: string; scanTriggered: boolean }> {
|
|
const payloadStr = JSON.stringify(payload);
|
|
|
|
if (!signature || !this.verifySignature(payloadStr, signature)) {
|
|
throw new Error("Webhook signature verification failed");
|
|
}
|
|
|
|
const eventTypeNormalized = this.normalizeEventType(eventType);
|
|
|
|
const event = await prisma.webhookEvent.create({
|
|
data: {
|
|
eventType: eventTypeNormalized,
|
|
payload: payloadStr,
|
|
source,
|
|
signature,
|
|
},
|
|
});
|
|
|
|
let scanTriggered = false;
|
|
|
|
if (eventTypeNormalized === WebhookEventType.SCAN_TRIGGER) {
|
|
const userId = payload.userId as string | undefined;
|
|
const source = (payload.dataSource as string) || undefined;
|
|
|
|
if (userId) {
|
|
scanTriggered = await this.triggerScanFromWebhook(event.id, userId, source);
|
|
}
|
|
}
|
|
|
|
await prisma.webhookEvent.update({
|
|
where: { id: event.id },
|
|
data: {
|
|
processed: true,
|
|
processedAt: new Date(),
|
|
},
|
|
});
|
|
|
|
return { eventId: event.id, scanTriggered };
|
|
}
|
|
|
|
/**
|
|
* Trigger a scan job from a webhook event.
|
|
*/
|
|
private async triggerScanFromWebhook(
|
|
eventId: string,
|
|
userId: string,
|
|
dataSource?: string
|
|
): Promise<boolean> {
|
|
try {
|
|
const user = await prisma.user.findUnique({ where: { id: userId } });
|
|
|
|
if (!user) {
|
|
return false;
|
|
}
|
|
|
|
const job = await prisma.scanJob.create({
|
|
data: {
|
|
userId,
|
|
status: "PENDING",
|
|
source: (dataSource as DataSource) || undefined,
|
|
scheduledBy: "webhook",
|
|
},
|
|
});
|
|
|
|
await prisma.webhookEvent.update({
|
|
where: { id: eventId },
|
|
data: { scanJobId: job.id },
|
|
});
|
|
|
|
return true;
|
|
} catch (err) {
|
|
console.error(`[Webhook] Scan trigger failed for event ${eventId}:`, err);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get webhook event history.
|
|
*/
|
|
async getEventHistory(limit = 50, offset = 0) {
|
|
return prisma.webhookEvent.findMany({
|
|
orderBy: { createdAt: "desc" },
|
|
take: limit,
|
|
skip: offset,
|
|
include: { scanJob: true },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get events for a specific user (via linked scan jobs).
|
|
*/
|
|
async getUserEvents(userId: string, limit = 50, offset = 0) {
|
|
return prisma.webhookEvent.findMany({
|
|
where: {
|
|
scanJob: { userId },
|
|
},
|
|
orderBy: { createdAt: "desc" },
|
|
take: limit,
|
|
skip: offset,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Process unprocessed webhook events (retry mechanism).
|
|
*/
|
|
async processPendingEvents(): Promise<number> {
|
|
const pending = await prisma.webhookEvent.findMany({
|
|
where: {
|
|
processed: false,
|
|
eventType: WebhookEventType.SCAN_TRIGGER,
|
|
},
|
|
orderBy: { createdAt: "asc" },
|
|
take: 50,
|
|
});
|
|
|
|
let processed = 0;
|
|
|
|
for (const event of pending) {
|
|
try {
|
|
const payload = JSON.parse(event.payload) as Record<string, unknown>;
|
|
const userId = payload.userId as string | undefined;
|
|
|
|
if (userId) {
|
|
const success = await this.triggerScanFromWebhook(
|
|
event.id,
|
|
userId,
|
|
payload.dataSource as string | undefined
|
|
);
|
|
|
|
if (success) {
|
|
await prisma.webhookEvent.update({
|
|
where: { id: event.id },
|
|
data: { processed: true, processedAt: new Date() },
|
|
});
|
|
processed++;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
console.error(`[Webhook] Retry failed for event ${event.id}:`, err);
|
|
}
|
|
}
|
|
|
|
return processed;
|
|
}
|
|
|
|
private computeSignature(payload: string): string {
|
|
return createHmac("sha256", this.secret).update(payload).digest("hex");
|
|
}
|
|
|
|
private normalizeEventType(eventType: string): WebhookEventType {
|
|
const upper = eventType.toUpperCase().replace(/\s+/g, "_");
|
|
const validTypes: WebhookEventType[] = [WebhookEventType.SCAN_TRIGGER, WebhookEventType.BREACH_DETECTED, WebhookEventType.SUBSCRIPTION_CHANGE];
|
|
if (!validTypes.includes(upper as WebhookEventType)) {
|
|
throw new Error(`Unknown event type: ${eventType}`);
|
|
}
|
|
return upper as WebhookEventType;
|
|
}
|
|
}
|