Add cross-service alert correlation system FRE-4500
- Unified alert types (AlertSource, AlertCategory, CorrelationStatus, EntityType) - NormalizedAlert and CorrelationGroup Prisma models - AlertNormalizer for all 4 services (DarkWatch, SpamShield, VoicePrint, CallAnalysis) - CorrelationEngine with temporal + entity-based correlation detection - CorrelationService orchestrator with dashboard API - Correlation API routes (/api/v1/correlation/*) - Service emitters wired to DarkWatch, SpamShield, VoicePrint - pnpm workspace config for monorepo
This commit is contained in:
246
packages/correlation/src/normalizer.ts
Normal file
246
packages/correlation/src/normalizer.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import {
|
||||
AlertSource,
|
||||
AlertCategory,
|
||||
Severity,
|
||||
EntityTypes,
|
||||
NormalizedAlertInput,
|
||||
} from "@shieldai/types";
|
||||
|
||||
type EntityType = (typeof EntityTypes)[keyof typeof EntityTypes];
|
||||
|
||||
interface DarkWatchAlertPayload {
|
||||
exposureId: string;
|
||||
breachName: string;
|
||||
severity: string;
|
||||
channel: string;
|
||||
dataType?: string[];
|
||||
dataSource?: string;
|
||||
}
|
||||
|
||||
interface SpamShieldAlertPayload {
|
||||
phoneNumber: string;
|
||||
decision: string;
|
||||
confidence: number;
|
||||
reasons?: string[];
|
||||
channel?: "call" | "sms";
|
||||
hiyaReputationScore?: number;
|
||||
truecallerSpamScore?: number;
|
||||
}
|
||||
|
||||
interface VoicePrintAlertPayload {
|
||||
jobId: string;
|
||||
verdict: string;
|
||||
syntheticScore: number;
|
||||
confidence: number;
|
||||
matchedEnrollmentId?: string;
|
||||
matchedSimilarity?: number;
|
||||
analysisType?: string;
|
||||
}
|
||||
|
||||
interface CallAnalysisAlertPayload {
|
||||
callId: string;
|
||||
eventType?: string;
|
||||
mosScore?: number;
|
||||
anomaly?: string;
|
||||
sentiment?: { label: string; score: number };
|
||||
}
|
||||
|
||||
const SEVERITY_MAP: Record<string, Severity> = {
|
||||
LOW: "LOW",
|
||||
INFO: "INFO",
|
||||
MEDIUM: "MEDIUM",
|
||||
WARNING: "WARNING",
|
||||
HIGH: "HIGH",
|
||||
CRITICAL: "CRITICAL",
|
||||
};
|
||||
|
||||
function mapSeverity(raw: string | number): Severity {
|
||||
if (typeof raw === "number") {
|
||||
if (raw >= 0.9) return "CRITICAL";
|
||||
if (raw >= 0.7) return "HIGH";
|
||||
if (raw >= 0.5) return "WARNING";
|
||||
if (raw >= 0.3) return "MEDIUM";
|
||||
if (raw >= 0.1) return "INFO";
|
||||
return "LOW";
|
||||
}
|
||||
const upper = raw.toUpperCase();
|
||||
return SEVERITY_MAP[upper] ?? "INFO";
|
||||
}
|
||||
|
||||
export class AlertNormalizer {
|
||||
public normalizeDarkWatchAlert(
|
||||
userId: string,
|
||||
sourceAlertId: string,
|
||||
payload: DarkWatchAlertPayload,
|
||||
timestamp?: Date
|
||||
): NormalizedAlertInput {
|
||||
const severity = mapSeverity(payload.severity);
|
||||
const entities: Array<{ type: EntityType; value: string }> = [];
|
||||
|
||||
if (payload.dataSource) {
|
||||
entities.push({ type: EntityTypes.EMAIL, value: payload.breachName });
|
||||
}
|
||||
|
||||
return {
|
||||
source: AlertSource.DARKWATCH,
|
||||
category: AlertCategory.BREACH_EXPOSURE,
|
||||
severity,
|
||||
userId,
|
||||
title: `Breach Exposure: ${payload.breachName}`,
|
||||
description: payload.dataType
|
||||
? `Data types exposed: ${payload.dataType.join(", ")} in ${payload.breachName}`
|
||||
: `Exposure detected in ${payload.breachName}`,
|
||||
entities,
|
||||
sourceAlertId,
|
||||
payload: payload as unknown as Record<string, unknown>,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
public normalizeSpamShieldAlert(
|
||||
userId: string,
|
||||
sourceAlertId: string,
|
||||
payload: SpamShieldAlertPayload,
|
||||
timestamp?: Date
|
||||
): NormalizedAlertInput {
|
||||
const decision = payload.decision.toUpperCase();
|
||||
const severity =
|
||||
decision === "BLOCK"
|
||||
? "HIGH"
|
||||
: decision === "FLAG"
|
||||
? "WARNING"
|
||||
: "INFO";
|
||||
|
||||
const channel = payload.channel === "sms" ? "sms" : "call";
|
||||
const category =
|
||||
channel === "sms"
|
||||
? AlertCategory.SPAM_SMS
|
||||
: AlertCategory.SPAM_CALL;
|
||||
|
||||
const entities: Array<{ type: EntityType; value: string }> = [
|
||||
{ type: EntityTypes.PHONE_NUMBER, value: payload.phoneNumber },
|
||||
];
|
||||
|
||||
return {
|
||||
source: AlertSource.SPAMSHIELD,
|
||||
category,
|
||||
severity,
|
||||
userId,
|
||||
title: `${channel === "sms" ? "SMS" : "Call"} ${decision}: ${payload.phoneNumber}`,
|
||||
description: payload.reasons
|
||||
? `SpamShield ${decision} decision. Reasons: ${payload.reasons.join(", ")}`
|
||||
: `SpamShield ${decision} decision with confidence ${Math.round(payload.confidence * 100)}%`,
|
||||
entities,
|
||||
sourceAlertId,
|
||||
payload: payload as unknown as Record<string, unknown>,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
public normalizeVoicePrintAlert(
|
||||
userId: string,
|
||||
sourceAlertId: string,
|
||||
payload: VoicePrintAlertPayload,
|
||||
timestamp?: Date
|
||||
): NormalizedAlertInput {
|
||||
const verdict = payload.verdict.toUpperCase();
|
||||
let severity: Severity;
|
||||
let category: AlertCategory;
|
||||
|
||||
if (payload.analysisType === "VOICE_MATCH" && payload.matchedEnrollmentId) {
|
||||
category = AlertCategory.VOICE_MISMATCH;
|
||||
severity =
|
||||
payload.matchedSimilarity !== undefined && payload.matchedSimilarity > 0.85
|
||||
? "MEDIUM"
|
||||
: "LOW";
|
||||
} else {
|
||||
category = AlertCategory.SYNTHETIC_VOICE;
|
||||
severity =
|
||||
verdict === "SYNTHETIC"
|
||||
? mapSeverity(payload.syntheticScore)
|
||||
: verdict === "UNCERTAIN"
|
||||
? "MEDIUM"
|
||||
: "INFO";
|
||||
}
|
||||
|
||||
const entities: Array<{ type: EntityType; value: string }> = [];
|
||||
if (payload.matchedEnrollmentId) {
|
||||
entities.push({ type: EntityTypes.USER_ID, value: payload.matchedEnrollmentId });
|
||||
}
|
||||
|
||||
return {
|
||||
source: AlertSource.VOICEPRINT,
|
||||
category,
|
||||
severity,
|
||||
userId,
|
||||
title: `Voice ${verdict}: Job ${payload.jobId}`,
|
||||
description: payload.analysisType
|
||||
? `Analysis type: ${payload.analysisType}. Verdict: ${verdict} (confidence: ${Math.round(payload.confidence * 100)}%)`
|
||||
: `Synthetic voice detection: ${verdict} (score: ${payload.syntheticScore.toFixed(3)})`,
|
||||
entities,
|
||||
sourceAlertId,
|
||||
payload: payload as unknown as Record<string, unknown>,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
public normalizeCallAnalysisAlert(
|
||||
userId: string,
|
||||
sourceAlertId: string,
|
||||
payload: CallAnalysisAlertPayload,
|
||||
timestamp?: Date
|
||||
): NormalizedAlertInput {
|
||||
let category: AlertCategory;
|
||||
let severity: Severity;
|
||||
let title: string;
|
||||
let description: string;
|
||||
|
||||
if (payload.anomaly) {
|
||||
category = AlertCategory.CALL_ANOMALY;
|
||||
severity = "WARNING";
|
||||
title = `Call Anomaly: ${payload.anomaly}`;
|
||||
description = `Anomaly "${payload.anomaly}" detected in call ${payload.callId}`;
|
||||
} else if (payload.mosScore !== undefined) {
|
||||
category = AlertCategory.CALL_QUALITY;
|
||||
severity =
|
||||
payload.mosScore < 2.5
|
||||
? "CRITICAL"
|
||||
: payload.mosScore < 3.5
|
||||
? "HIGH"
|
||||
: payload.mosScore < 4.0
|
||||
? "MEDIUM"
|
||||
: "INFO";
|
||||
title = `Call Quality: MOS ${payload.mosScore.toFixed(1)}`;
|
||||
description = `MOS score ${payload.mosScore.toFixed(1)} for call ${payload.callId}`;
|
||||
} else if (payload.eventType) {
|
||||
category = AlertCategory.CALL_EVENT;
|
||||
severity = "INFO";
|
||||
title = `Call Event: ${payload.eventType}`;
|
||||
description = `Event "${payload.eventType}" during call ${payload.callId}`;
|
||||
} else {
|
||||
category = AlertCategory.CALL_EVENT;
|
||||
severity = "INFO";
|
||||
title = `Call Alert: ${payload.callId}`;
|
||||
description = `Alert for call ${payload.callId}`;
|
||||
}
|
||||
|
||||
const entities: Array<{ type: EntityType; value: string }> = [
|
||||
{ type: EntityTypes.CALL_ID, value: payload.callId },
|
||||
];
|
||||
|
||||
return {
|
||||
source: AlertSource.CALL_ANALYSIS,
|
||||
category,
|
||||
severity,
|
||||
userId,
|
||||
title,
|
||||
description,
|
||||
entities,
|
||||
sourceAlertId,
|
||||
payload: payload as unknown as Record<string, unknown>,
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const alertNormalizer = new AlertNormalizer();
|
||||
Reference in New Issue
Block a user