import { prisma, ExposureSource, ExposureSeverity, WatchlistType, AlertType, AlertSeverity } from '@shieldsai/shared-db'; import { createHash } from 'crypto'; import { mixpanelService, EventType } from '@shieldsai/shared-analytics'; function hashIdentifier(identifier: string): string { return createHash('sha256').update(identifier.toLowerCase().trim()).digest('hex'); } function determineSeverity( source: ExposureSource, dataType: WatchlistType ): ExposureSeverity { const criticalSources = [ExposureSource.darkWebForum, ExposureSource.honeypot]; const warningSources = [ExposureSource.hibp, ExposureSource.shodan]; const criticalTypes = [WatchlistType.ssn]; if (criticalTypes.includes(dataType)) return ExposureSeverity.critical; if (criticalSources.includes(source)) return ExposureSeverity.critical; if (warningSources.includes(source)) return ExposureSeverity.warning; return ExposureSeverity.info; } export interface WebhookPayload { source: string; identifier: string; identifierType: string; metadata?: Record; timestamp?: string; } export class WebhookService { async processExternalWebhook(payload: WebhookPayload): Promise<{ exposuresCreated: number; alertsCreated: number; }> { const source = this.mapSource(payload.source); const dataType = this.mapDataType(payload.identifierType); const identifier = payload.identifier.toLowerCase().trim(); const identifierHash = hashIdentifier(identifier); const severity = determineSeverity(source, dataType); const matchingItems = await prisma.watchlistItem.findMany({ where: { isActive: true, OR: [ { hash: identifierHash, type: dataType }, { value: identifier, type: dataType }, ], }, include: { subscription: { select: { id: true, tier: true, userId: true, }, }, }, }); let exposuresCreated = 0; let alertsCreated = 0; for (const item of matchingItems) { const existing = await prisma.exposure.findFirst({ where: { subscriptionId: item.subscriptionId, source, identifierHash, }, }); if (existing) { await prisma.exposure.update({ where: { id: existing.id }, data: { detectedAt: new Date() }, }); continue; } const exposure = await prisma.exposure.create({ data: { subscriptionId: item.subscriptionId, watchlistItemId: item.id, source, dataType, identifier, identifierHash, severity, isFirstTime: true, metadata: payload.metadata || {}, detectedAt: new Date(), }, }); exposuresCreated++; const alertChannels = this.getAlertChannelsForTier(item.subscription.tier); await prisma.alert.create({ data: { subscriptionId: item.subscriptionId, userId: item.subscription.userId, exposureId: exposure.id, type: AlertType.exposure_detected, title: `New Exposure Detected: ${this.getSourceLabel(source)}`, message: this.buildAlertMessage(identifier, source, severity), severity: this.mapAlertSeverity(severity), channel: alertChannels, }, }); alertsCreated++; await mixpanelService.track(EventType.EXPOSURE_DETECTED, { userId: item.subscription.userId, exposureType: dataType, severity, source, subscriptionTier: item.subscription.tier, }); } return { exposuresCreated, alertsCreated }; } async verifyWebhookSignature( body: string, signature: string, timestamp: string ): Promise { const webhookSecret = process.env.DARKWATCH_WEBHOOK_SECRET; if (!webhookSecret) { console.warn('[WebhookService] DARKWATCH_WEBHOOK_SECRET not set — signature verification skipped'); return false; } const expected = createHash('sha256') .update(`${timestamp}:${body}`) .digest('hex'); return expected === signature; } private mapSource(source: string): ExposureSource { const sourceMap: Record = { hibp: ExposureSource.hibp, 'haveibeenpwned': ExposureSource.hibp, securitytrails: ExposureSource.securityTrails, censys: ExposureSource.censys, 'darkweb-forum': ExposureSource.darkWebForum, 'darkweb': ExposureSource.darkWebForum, shodan: ExposureSource.shodan, honeypot: ExposureSource.honeypot, }; const normalized = source.toLowerCase().replace(/\s+/g, ''); const mapped = sourceMap[normalized]; if (!mapped) { console.warn(`[WebhookService] Unknown source "${source}", falling back to darkWebForum`); } return mapped || ExposureSource.darkWebForum; } private mapDataType(type: string): WatchlistType { const typeMap: Record = { email: WatchlistType.email, phone: WatchlistType.phoneNumber, phonenumber: WatchlistType.phoneNumber, ssn: WatchlistType.ssn, address: WatchlistType.address, domain: WatchlistType.domain, }; const normalized = type.toLowerCase().trim(); return typeMap[normalized] || WatchlistType.email; } private getAlertChannelsForTier(tier: string): string[] { const channelMap: Record = { basic: ['email'], plus: ['email', 'push'], premium: ['email', 'push', 'sms'], }; return channelMap[tier] || ['email']; } private mapAlertSeverity(severity: ExposureSeverity): AlertSeverity { return severity as AlertSeverity; } private getSourceLabel(source: ExposureSource): string { const labels: Record = { [ExposureSource.hibp]: 'Have I Been Pwned', [ExposureSource.securityTrails]: 'SecurityTrails', [ExposureSource.censys]: 'Censys', [ExposureSource.darkWebForum]: 'Dark Web Forum', [ExposureSource.shodan]: 'Shodan', [ExposureSource.honeypot]: 'Honeypot', }; return labels[source] || source; } private buildAlertMessage( identifier: string, source: ExposureSource, severity: ExposureSeverity ): string { const masked = this.maskIdentifier(identifier); return `${severity.toUpperCase()}: "${masked}" found in ${this.getSourceLabel(source)}.`; } private maskIdentifier(identifier: string): string { if (identifier.includes('@')) { const [user, domain] = identifier.split('@'); const maskedUser = user.slice(0, 2) + '***' + user.slice(-1); return `${maskedUser}@${domain}`; } if (identifier.length > 8) { return identifier.slice(0, 3) + '***' + identifier.slice(-2); } return identifier; } } export const webhookService = new WebhookService();