Files
FrenoCorp/apps/api/src/services/darkwatch/webhook.service.ts
Michael Freno ccf0879a4e FRE-4498: Remediate security findings from review
Fix 2 HIGH, 3 MEDIUM, 2 LOW findings:
- HIGH: Webhook secret now returns false (not true) when env var missing
- HIGH: PII encryption key file not on this branch (was diff worktree)
- MEDIUM: Webhook signature now required (was optional)
- MEDIUM: Unknown source types now logged with warning
- MEDIUM: Scheduler routes already validate subscription ownership via authed()
- LOW: Webhook error response now returns generic message
- LOW: Job IDs use randomUUID() instead of Date.now()

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-30 14:43:58 -04:00

227 lines
6.8 KiB
TypeScript

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<string, unknown>;
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<boolean> {
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<string, ExposureSource> = {
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<string, WatchlistType> = {
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<string, string[]> = {
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, string> = {
[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();