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:
1
.turbo/cache/aacbad09f9d0c28b-manifest.json
vendored
Normal file
1
.turbo/cache/aacbad09f9d0c28b-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"files":{"packages/db/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/db/dist/services/field-encryption.service.d.ts.map":{"size":330,"mtime_nanos":1777698592443009097,"mode":420,"is_dir":false},"packages/db/.turbo/turbo-build.log":{"size":511,"mtime_nanos":1777698592481009929,"mode":420,"is_dir":false},"packages/db/dist/index.js":{"size":535,"mtime_nanos":1777698592446009163,"mode":420,"is_dir":false},"packages/db/dist/services/field-encryption.service.d.ts":{"size":252,"mtime_nanos":1777698592443009097,"mode":420,"is_dir":false},"packages/db/dist/index.js.map":{"size":217,"mtime_nanos":1777698592446009163,"mode":420,"is_dir":false},"packages/db/dist/services/field-encryption.service.js":{"size":1606,"mtime_nanos":1777698592439009009,"mode":420,"is_dir":false},"packages/db/dist/services/field-encryption.service.js.map":{"size":1414,"mtime_nanos":1777698592439009009,"mode":420,"is_dir":false},"packages/db/dist/services":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/db/dist/index.d.ts.map":{"size":308,"mtime_nanos":1777698592459009447,"mode":420,"is_dir":false},"packages/db/dist/index.d.ts":{"size":405,"mtime_nanos":1777698592459009447,"mode":420,"is_dir":false}},"order":["packages/db/.turbo/turbo-build.log","packages/db/dist","packages/db/dist/index.d.ts","packages/db/dist/index.d.ts.map","packages/db/dist/index.js","packages/db/dist/index.js.map","packages/db/dist/services","packages/db/dist/services/field-encryption.service.d.ts","packages/db/dist/services/field-encryption.service.d.ts.map","packages/db/dist/services/field-encryption.service.js","packages/db/dist/services/field-encryption.service.js.map"]}
|
||||||
1
.turbo/cache/aacbad09f9d0c28b-meta.json
vendored
Normal file
1
.turbo/cache/aacbad09f9d0c28b-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"aacbad09f9d0c28b","duration":1972,"sha":"685fb57e53b5d01707795f6ec6f119356e0bfd12","dirty_hash":"0908f7ed09b46b26ba2dfc1c94e994cefe9e2f178fad10e9c8483f8ee168d061"}
|
||||||
BIN
.turbo/cache/aacbad09f9d0c28b.tar.zst
vendored
Normal file
BIN
.turbo/cache/aacbad09f9d0c28b.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/dbd09b3775d9469c-manifest.json
vendored
Normal file
1
.turbo/cache/dbd09b3775d9469c-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"files":{"packages/types/.turbo/turbo-build.log":{"size":78,"mtime_nanos":1777698591363985482,"mode":420,"is_dir":false},"packages/types/dist/index.d.ts.map":{"size":5437,"mtime_nanos":1777698591336984892,"mode":420,"is_dir":false},"packages/types/dist/requestId.d.ts":{"size":519,"mtime_nanos":1777698591309984301,"mode":420,"is_dir":false},"packages/types/dist/requestId.d.ts.map":{"size":276,"mtime_nanos":1777698591309984301,"mode":420,"is_dir":false},"packages/types/dist/requestId.js":{"size":1383,"mtime_nanos":1777698591304984191,"mode":420,"is_dir":false},"packages/types/dist/index.d.ts":{"size":7670,"mtime_nanos":1777698591336984892,"mode":420,"is_dir":false},"packages/types/dist/index.js.map":{"size":2044,"mtime_nanos":1777698591318984498,"mode":420,"is_dir":false},"packages/types/dist/requestId.js.map":{"size":1299,"mtime_nanos":1777698591304984191,"mode":420,"is_dir":false},"packages/types/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/types/dist/index.js":{"size":3106,"mtime_nanos":1777698591319984520,"mode":420,"is_dir":false}},"order":["packages/types/.turbo/turbo-build.log","packages/types/dist","packages/types/dist/index.d.ts","packages/types/dist/index.d.ts.map","packages/types/dist/index.js","packages/types/dist/index.js.map","packages/types/dist/requestId.d.ts","packages/types/dist/requestId.d.ts.map","packages/types/dist/requestId.js","packages/types/dist/requestId.js.map"]}
|
||||||
1
.turbo/cache/dbd09b3775d9469c-meta.json
vendored
Normal file
1
.turbo/cache/dbd09b3775d9469c-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"dbd09b3775d9469c","duration":855,"sha":"685fb57e53b5d01707795f6ec6f119356e0bfd12","dirty_hash":"0908f7ed09b46b26ba2dfc1c94e994cefe9e2f178fad10e9c8483f8ee168d061"}
|
||||||
BIN
.turbo/cache/dbd09b3775d9469c.tar.zst
vendored
Normal file
BIN
.turbo/cache/dbd09b3775d9469c.tar.zst
vendored
Normal file
Binary file not shown.
1
.turbo/cache/f810866ff5911e6a-manifest.json
vendored
Normal file
1
.turbo/cache/f810866ff5911e6a-manifest.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"files":{"packages/shared-billing/dist/models/subscription.model.js":{"size":1577,"mtime_nanos":1777698591971998787,"mode":420,"is_dir":false},"packages/shared-billing/dist/middleware":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/shared-billing/dist/config/billing.config.js":{"size":3740,"mtime_nanos":1777698591945998218,"mode":420,"is_dir":false},"packages/shared-billing/dist/services/billing.service.d.ts":{"size":2511,"mtime_nanos":1777698592000999421,"mode":420,"is_dir":false},"packages/shared-billing/dist/services/billing.service.d.ts.map":{"size":1804,"mtime_nanos":1777698592000999421,"mode":420,"is_dir":false},"packages/shared-billing/dist/services/billing.service.js.map":{"size":6458,"mtime_nanos":1777698591993999268,"mode":420,"is_dir":false},"packages/shared-billing/dist":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/shared-billing/dist/config/billing.config.d.ts":{"size":8876,"mtime_nanos":1777698591967998699,"mode":420,"is_dir":false},"packages/shared-billing/dist/index.js":{"size":2386,"mtime_nanos":1777698592015999750,"mode":420,"is_dir":false},"packages/shared-billing/dist/config":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/shared-billing/dist/index.js.map":{"size":352,"mtime_nanos":1777698592015999750,"mode":420,"is_dir":false},"packages/shared-billing/dist/models/subscription.model.d.ts":{"size":3467,"mtime_nanos":1777698591977998918,"mode":420,"is_dir":false},"packages/shared-billing/dist/models/subscription.model.js.map":{"size":1431,"mtime_nanos":1777698591971998787,"mode":420,"is_dir":false},"packages/shared-billing/dist/middleware/billing.middleware.d.ts.map":{"size":1125,"mtime_nanos":1777698592011999662,"mode":420,"is_dir":false},"packages/shared-billing/dist/middleware/billing.middleware.js":{"size":4164,"mtime_nanos":1777698592006999552,"mode":420,"is_dir":false},"packages/shared-billing/dist/models":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/shared-billing/dist/models/subscription.model.d.ts.map":{"size":434,"mtime_nanos":1777698591976998896,"mode":420,"is_dir":false},"packages/shared-billing/dist/services/billing.service.js":{"size":7312,"mtime_nanos":1777698591993999268,"mode":420,"is_dir":false},"packages/shared-billing/dist/index.d.ts":{"size":359,"mtime_nanos":1777698592015999750,"mode":420,"is_dir":false},"packages/shared-billing/dist/config/billing.config.d.ts.map":{"size":664,"mtime_nanos":1777698591967998699,"mode":420,"is_dir":false},"packages/shared-billing/dist/middleware/billing.middleware.d.ts":{"size":1176,"mtime_nanos":1777698592011999662,"mode":420,"is_dir":false},"packages/shared-billing/.turbo/turbo-build.log":{"size":96,"mtime_nanos":1777698592050000494,"mode":420,"is_dir":false},"packages/shared-billing/dist/index.d.ts.map":{"size":317,"mtime_nanos":1777698592015999750,"mode":420,"is_dir":false},"packages/shared-billing/dist/middleware/billing.middleware.js.map":{"size":3848,"mtime_nanos":1777698592006999552,"mode":420,"is_dir":false},"packages/shared-billing/dist/services":{"size":0,"mtime_nanos":0,"mode":0,"is_dir":true},"packages/shared-billing/dist/config/billing.config.js.map":{"size":3157,"mtime_nanos":1777698591945998218,"mode":420,"is_dir":false}},"order":["packages/shared-billing/.turbo/turbo-build.log","packages/shared-billing/dist","packages/shared-billing/dist/config","packages/shared-billing/dist/config/billing.config.d.ts","packages/shared-billing/dist/config/billing.config.d.ts.map","packages/shared-billing/dist/config/billing.config.js","packages/shared-billing/dist/config/billing.config.js.map","packages/shared-billing/dist/index.d.ts","packages/shared-billing/dist/index.d.ts.map","packages/shared-billing/dist/index.js","packages/shared-billing/dist/index.js.map","packages/shared-billing/dist/middleware","packages/shared-billing/dist/middleware/billing.middleware.d.ts","packages/shared-billing/dist/middleware/billing.middleware.d.ts.map","packages/shared-billing/dist/middleware/billing.middleware.js","packages/shared-billing/dist/middleware/billing.middleware.js.map","packages/shared-billing/dist/models","packages/shared-billing/dist/models/subscription.model.d.ts","packages/shared-billing/dist/models/subscription.model.d.ts.map","packages/shared-billing/dist/models/subscription.model.js","packages/shared-billing/dist/models/subscription.model.js.map","packages/shared-billing/dist/services","packages/shared-billing/dist/services/billing.service.d.ts","packages/shared-billing/dist/services/billing.service.d.ts.map","packages/shared-billing/dist/services/billing.service.js","packages/shared-billing/dist/services/billing.service.js.map"]}
|
||||||
1
.turbo/cache/f810866ff5911e6a-meta.json
vendored
Normal file
1
.turbo/cache/f810866ff5911e6a-meta.json
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hash":"f810866ff5911e6a","duration":1541,"sha":"685fb57e53b5d01707795f6ec6f119356e0bfd12","dirty_hash":"0908f7ed09b46b26ba2dfc1c94e994cefe9e2f178fad10e9c8483f8ee168d061"}
|
||||||
BIN
.turbo/cache/f810866ff5911e6a.tar.zst
vendored
Normal file
BIN
.turbo/cache/f810866ff5911e6a.tar.zst
vendored
Normal file
Binary file not shown.
@@ -22,5 +22,6 @@
|
|||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
}
|
},
|
||||||
|
"packageManager": "pnpm@9.0.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,11 @@
|
|||||||
"@fastify/helmet": "^13.0.1",
|
"@fastify/helmet": "^13.0.1",
|
||||||
"@fastify/rate-limit": "^9.0.0",
|
"@fastify/rate-limit": "^9.0.0",
|
||||||
"@fastify/sensible": "^6.0.1",
|
"@fastify/sensible": "^6.0.1",
|
||||||
"@shieldai/db": "0.1.0",
|
"@shieldai/db": "workspace:*",
|
||||||
"@shieldai/types": "0.1.0",
|
"@shieldai/types": "workspace:*",
|
||||||
|
"@shieldai/correlation": "workspace:*",
|
||||||
"fastify": "^5.2.0",
|
"fastify": "^5.2.0",
|
||||||
"@shieldai/darkwatch": "0.1.0",
|
"@shieldai/darkwatch": "workspace:*",
|
||||||
"@shieldai/voiceprint": "0.1.0"
|
"@shieldai/voiceprint": "workspace:*"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
151
packages/api/src/routes/correlation.routes.ts
Normal file
151
packages/api/src/routes/correlation.routes.ts
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { FastifyInstance } from "fastify";
|
||||||
|
import { correlationService } from "@shieldai/correlation";
|
||||||
|
|
||||||
|
export function correlationRoutes(fastify: FastifyInstance) {
|
||||||
|
fastify.get("/dashboard", async (request, reply) => {
|
||||||
|
const userId = (request.user as { id: string })?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return reply.code(401).send({ error: "User not authenticated" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeWindow = parseInt((request.query as any).timeWindow as string) || 60;
|
||||||
|
const data = await correlationService.getDashboardData(userId, timeWindow);
|
||||||
|
return reply.send(data);
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get("/groups", async (request, reply) => {
|
||||||
|
const userId = (request.user as { id: string })?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return reply.code(401).send({ error: "User not authenticated" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = request.query as Record<string, string>;
|
||||||
|
const result = await correlationService.getCorrelationGroups({
|
||||||
|
userId,
|
||||||
|
status: query.status || undefined,
|
||||||
|
timeWindowMinutes: query.timeWindow
|
||||||
|
? parseInt(query.timeWindow)
|
||||||
|
: 60,
|
||||||
|
limit: query.limit ? parseInt(query.limit) : 50,
|
||||||
|
offset: query.offset ? parseInt(query.offset) : 0,
|
||||||
|
});
|
||||||
|
return reply.send(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get("/groups/:groupId", async (request, reply) => {
|
||||||
|
const groupId = (request.params as any).groupId;
|
||||||
|
const group = await correlationService.getGroupById(groupId);
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
return reply.code(404).send({ error: "Correlation group not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.send(group);
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.patch("/groups/:groupId/resolve", async (request, reply) => {
|
||||||
|
const groupId = (request.params as any).groupId;
|
||||||
|
const body = (request.body as any) || {};
|
||||||
|
const status = body.status || "RESOLVED";
|
||||||
|
const group = await correlationService.resolveGroup(groupId, status);
|
||||||
|
|
||||||
|
if (!group) {
|
||||||
|
return reply.code(404).send({ error: "Correlation group not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.send(group);
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get("/alerts", async (request, reply) => {
|
||||||
|
const userId = (request.user as { id: string })?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return reply.code(401).send({ error: "User not authenticated" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = request.query as Record<string, string>;
|
||||||
|
const result = await correlationService.getCorrelatedAlerts({
|
||||||
|
userId,
|
||||||
|
source: query.source || undefined,
|
||||||
|
category: query.category || undefined,
|
||||||
|
severity: query.severity || undefined,
|
||||||
|
timeWindowMinutes: query.timeWindow
|
||||||
|
? parseInt(query.timeWindow)
|
||||||
|
: 60,
|
||||||
|
limit: query.limit ? parseInt(query.limit) : 50,
|
||||||
|
offset: query.offset ? parseInt(query.offset) : 0,
|
||||||
|
});
|
||||||
|
return reply.send(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post("/ingest/darkwatch", async (request, reply) => {
|
||||||
|
const body = request.body as any;
|
||||||
|
const alert = await correlationService.ingestDarkWatchAlert(
|
||||||
|
body.userId,
|
||||||
|
body.sourceAlertId,
|
||||||
|
{
|
||||||
|
exposureId: body.exposureId,
|
||||||
|
breachName: body.breachName,
|
||||||
|
severity: body.severity,
|
||||||
|
channel: body.channel,
|
||||||
|
dataType: body.dataType,
|
||||||
|
dataSource: body.dataSource,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return reply.code(201).send(alert);
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post("/ingest/spamshield", async (request, reply) => {
|
||||||
|
const body = request.body as any;
|
||||||
|
const alert = await correlationService.ingestSpamShieldAlert(
|
||||||
|
body.userId,
|
||||||
|
body.sourceAlertId,
|
||||||
|
{
|
||||||
|
phoneNumber: body.phoneNumber,
|
||||||
|
decision: body.decision,
|
||||||
|
confidence: body.confidence,
|
||||||
|
reasons: body.reasons,
|
||||||
|
channel: body.channel,
|
||||||
|
hiyaReputationScore: body.hiyaReputationScore,
|
||||||
|
truecallerSpamScore: body.truecallerSpamScore,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return reply.code(201).send(alert);
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post("/ingest/voiceprint", async (request, reply) => {
|
||||||
|
const body = request.body as any;
|
||||||
|
const alert = await correlationService.ingestVoicePrintAlert(
|
||||||
|
body.userId,
|
||||||
|
body.sourceAlertId,
|
||||||
|
{
|
||||||
|
jobId: body.jobId,
|
||||||
|
verdict: body.verdict,
|
||||||
|
syntheticScore: body.syntheticScore,
|
||||||
|
confidence: body.confidence,
|
||||||
|
matchedEnrollmentId: body.matchedEnrollmentId,
|
||||||
|
matchedSimilarity: body.matchedSimilarity,
|
||||||
|
analysisType: body.analysisType,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return reply.code(201).send(alert);
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post("/ingest/call-analysis", async (request, reply) => {
|
||||||
|
const body = request.body as any;
|
||||||
|
const alert = await correlationService.ingestCallAnalysisAlert(
|
||||||
|
body.userId,
|
||||||
|
body.sourceAlertId,
|
||||||
|
{
|
||||||
|
callId: body.callId,
|
||||||
|
eventType: body.eventType,
|
||||||
|
mosScore: body.mosScore,
|
||||||
|
anomaly: body.anomaly,
|
||||||
|
sentiment: body.sentiment,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return reply.code(201).send(alert);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -24,3 +24,10 @@ export function voiceprintRoutes(fastify: FastifyInstance) {
|
|||||||
root.register(voiceprint);
|
root.register(voiceprint);
|
||||||
}, { prefix: "/api/v1/voiceprint" });
|
}, { prefix: "/api/v1/voiceprint" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function correlationRoutes(fastify: FastifyInstance) {
|
||||||
|
fastify.register(async (root) => {
|
||||||
|
const correlation = (await import("./correlation.routes")).correlationRoutes;
|
||||||
|
root.register(correlation);
|
||||||
|
}, { prefix: "/api/v1/correlation" });
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import cors from "@fastify/cors";
|
|||||||
import helmet from "@fastify/helmet";
|
import helmet from "@fastify/helmet";
|
||||||
import sensible from "@fastify/sensible";
|
import sensible from "@fastify/sensible";
|
||||||
import { extractOrGenerateRequestId } from "@shieldai/types";
|
import { extractOrGenerateRequestId } from "@shieldai/types";
|
||||||
import { darkwatchRoutes, voiceprintRoutes } from "./routes";
|
import { darkwatchRoutes, voiceprintRoutes, correlationRoutes } from "./routes";
|
||||||
|
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
logger: {
|
logger: {
|
||||||
@@ -27,6 +27,7 @@ async function bootstrap() {
|
|||||||
|
|
||||||
await app.register(darkwatchRoutes);
|
await app.register(darkwatchRoutes);
|
||||||
await app.register(voiceprintRoutes);
|
await app.register(voiceprintRoutes);
|
||||||
|
await app.register(correlationRoutes);
|
||||||
|
|
||||||
app.get("/health", async () => ({ status: "ok", timestamp: new Date().toISOString() }));
|
app.get("/health", async () => ({ status: "ok", timestamp: new Date().toISOString() }));
|
||||||
|
|
||||||
|
|||||||
17
packages/correlation/package.json
Normal file
17
packages/correlation/package.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "@shieldai/correlation",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.js",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"lint": "eslint src/"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@shieldai/db": "workspace:*",
|
||||||
|
"@shieldai/types": "workspace:*"
|
||||||
|
},
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
}
|
||||||
|
}
|
||||||
98
packages/correlation/src/emitter.ts
Normal file
98
packages/correlation/src/emitter.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { correlationService } from "@shieldai/correlation";
|
||||||
|
|
||||||
|
export async function emitDarkWatchAlert(
|
||||||
|
userId: string,
|
||||||
|
exposureId: string,
|
||||||
|
alertId: string,
|
||||||
|
breachName: string,
|
||||||
|
severity: string,
|
||||||
|
channel: string,
|
||||||
|
dataType?: string[],
|
||||||
|
dataSource?: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await correlationService.ingestDarkWatchAlert(userId, alertId, {
|
||||||
|
exposureId,
|
||||||
|
breachName,
|
||||||
|
severity,
|
||||||
|
channel,
|
||||||
|
dataType,
|
||||||
|
dataSource,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Correlation] DarkWatch alert emit failed:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function emitSpamShieldAlert(
|
||||||
|
userId: string,
|
||||||
|
analysisId: string,
|
||||||
|
phoneNumber: string,
|
||||||
|
decision: string,
|
||||||
|
confidence: number,
|
||||||
|
reasons?: string[],
|
||||||
|
channel?: "call" | "sms",
|
||||||
|
hiyaReputationScore?: number,
|
||||||
|
truecallerSpamScore?: number
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await correlationService.ingestSpamShieldAlert(userId, analysisId, {
|
||||||
|
phoneNumber,
|
||||||
|
decision,
|
||||||
|
confidence,
|
||||||
|
reasons,
|
||||||
|
channel,
|
||||||
|
hiyaReputationScore,
|
||||||
|
truecallerSpamScore,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Correlation] SpamShield alert emit failed:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function emitVoicePrintAlert(
|
||||||
|
userId: string,
|
||||||
|
jobId: string,
|
||||||
|
verdict: string,
|
||||||
|
syntheticScore: number,
|
||||||
|
confidence: number,
|
||||||
|
matchedEnrollmentId?: string,
|
||||||
|
matchedSimilarity?: number,
|
||||||
|
analysisType?: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await correlationService.ingestVoicePrintAlert(userId, jobId, {
|
||||||
|
jobId,
|
||||||
|
verdict,
|
||||||
|
syntheticScore,
|
||||||
|
confidence,
|
||||||
|
matchedEnrollmentId,
|
||||||
|
matchedSimilarity,
|
||||||
|
analysisType,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Correlation] VoicePrint alert emit failed:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function emitCallAnalysisAlert(
|
||||||
|
userId: string,
|
||||||
|
callId: string,
|
||||||
|
eventType?: string,
|
||||||
|
mosScore?: number,
|
||||||
|
anomaly?: string,
|
||||||
|
sentiment?: { label: string; score: number }
|
||||||
|
): Promise<void> {
|
||||||
|
const sourceAlertId = `call-${callId}-${Date.now()}`;
|
||||||
|
try {
|
||||||
|
await correlationService.ingestCallAnalysisAlert(userId, sourceAlertId, {
|
||||||
|
callId,
|
||||||
|
eventType,
|
||||||
|
mosScore,
|
||||||
|
anomaly,
|
||||||
|
sentiment,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Correlation] CallAnalysis alert emit failed:`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
422
packages/correlation/src/engine.ts
Normal file
422
packages/correlation/src/engine.ts
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
import { prisma } from "@shieldai/db";
|
||||||
|
import {
|
||||||
|
AlertSource,
|
||||||
|
AlertCategory,
|
||||||
|
Severity,
|
||||||
|
EntityType,
|
||||||
|
CorrelationStatus,
|
||||||
|
NormalizedAlertInput,
|
||||||
|
CorrelationGroupOutput,
|
||||||
|
CorrelatedAlertOutput,
|
||||||
|
CorrelationQuery,
|
||||||
|
} from "@shieldai/types";
|
||||||
|
import { alertNormalizer, AlertNormalizer } from "./normalizer";
|
||||||
|
|
||||||
|
const SEVERITY_RANK: Record<string, number> = {
|
||||||
|
LOW: 0,
|
||||||
|
INFO: 1,
|
||||||
|
MEDIUM: 2,
|
||||||
|
WARNING: 3,
|
||||||
|
HIGH: 4,
|
||||||
|
CRITICAL: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
function higherSeverity(a: string, b: string): string {
|
||||||
|
return SEVERITY_RANK[a] >= SEVERITY_RANK[b] ? a : b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function entitiesOverlap(
|
||||||
|
a: Array<{ type: string; value: string }>,
|
||||||
|
b: Array<{ type: string; value: string }>
|
||||||
|
): boolean {
|
||||||
|
for (const ea of a) {
|
||||||
|
for (const eb of b) {
|
||||||
|
if (ea.type === eb.type && ea.value.toLowerCase() === eb.value.toLowerCase()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
type AlertRow = {
|
||||||
|
id: string;
|
||||||
|
source: string;
|
||||||
|
category: string;
|
||||||
|
severity: string;
|
||||||
|
userId: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
entities: unknown;
|
||||||
|
sourceAlertId: string;
|
||||||
|
groupId: string | null;
|
||||||
|
payload: unknown;
|
||||||
|
createdAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GroupRow = {
|
||||||
|
id: string;
|
||||||
|
userId: string;
|
||||||
|
entities: unknown;
|
||||||
|
highestSeverity: string;
|
||||||
|
status: string;
|
||||||
|
alertCount: number;
|
||||||
|
summary: string | null;
|
||||||
|
resolvedAt: Date | null;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class CorrelationEngine {
|
||||||
|
private readonly timeWindowMinutes: number;
|
||||||
|
|
||||||
|
constructor(timeWindowMinutes: number = 30) {
|
||||||
|
this.timeWindowMinutes = timeWindowMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ingestAlert(input: NormalizedAlertInput): Promise<CorrelatedAlertOutput> {
|
||||||
|
const alert = await (prisma as any).normalizedAlert.create({
|
||||||
|
data: {
|
||||||
|
source: input.source,
|
||||||
|
category: input.category,
|
||||||
|
severity: input.severity,
|
||||||
|
userId: input.userId,
|
||||||
|
title: input.title,
|
||||||
|
description: input.description,
|
||||||
|
entities: input.entities,
|
||||||
|
sourceAlertId: input.sourceAlertId,
|
||||||
|
payload: input.payload,
|
||||||
|
createdAt: input.timestamp || new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const correlation = await this.findOrCreateCorrelation(alert as AlertRow);
|
||||||
|
|
||||||
|
if (correlation) {
|
||||||
|
await (prisma as any).normalizedAlert.update({
|
||||||
|
where: { id: alert.id },
|
||||||
|
data: { groupId: correlation.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
const updated = await (prisma as any).normalizedAlert.findUnique({
|
||||||
|
where: { id: alert.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.toOutput(updated as AlertRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.toOutput(alert as AlertRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findOrCreateCorrelation(
|
||||||
|
alert: AlertRow
|
||||||
|
): Promise<GroupRow | null> {
|
||||||
|
const cutoff = new Date(Date.now() - this.timeWindowMinutes * 60 * 1000);
|
||||||
|
|
||||||
|
const existingGroups = await (prisma as any).correlationGroup.findMany({
|
||||||
|
where: {
|
||||||
|
userId: alert.userId,
|
||||||
|
status: CorrelationStatus.ACTIVE,
|
||||||
|
createdAt: { gte: cutoff },
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
alerts: {
|
||||||
|
where: { createdAt: { gte: cutoff } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const alertEntities = alert.entities as Array<{ type: string; value: string }>;
|
||||||
|
|
||||||
|
for (const group of existingGroups) {
|
||||||
|
const groupEntities = group.entities as Array<{ type: string; value: string }>;
|
||||||
|
|
||||||
|
if (entitiesOverlap(groupEntities, alertEntities)) {
|
||||||
|
const newSeverity = higherSeverity(
|
||||||
|
group.highestSeverity,
|
||||||
|
alert.severity
|
||||||
|
);
|
||||||
|
|
||||||
|
const updatedGroup = await (prisma as any).correlationGroup.update({
|
||||||
|
where: { id: group.id },
|
||||||
|
data: {
|
||||||
|
highestSeverity: newSeverity,
|
||||||
|
alertCount: group.alertCount + 1,
|
||||||
|
entities: this.mergeEntities(groupEntities, alertEntities),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return updatedGroup;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueSources = new Set<string>();
|
||||||
|
uniqueSources.add(alert.source);
|
||||||
|
|
||||||
|
const uniqueCategories = new Set<string>();
|
||||||
|
uniqueCategories.add(alert.category);
|
||||||
|
|
||||||
|
if (uniqueSources.size > 1 || uniqueCategories.size > 1) {
|
||||||
|
const newGroup = await (prisma as any).correlationGroup.create({
|
||||||
|
data: {
|
||||||
|
userId: alert.userId,
|
||||||
|
entities: alert.entities,
|
||||||
|
highestSeverity: alert.severity,
|
||||||
|
status: CorrelationStatus.ACTIVE,
|
||||||
|
alertCount: 1,
|
||||||
|
summary: this.generateSummary(
|
||||||
|
alert.source,
|
||||||
|
alert.category,
|
||||||
|
alert.title
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return newGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mergeEntities(
|
||||||
|
a: Array<{ type: string; value: string }>,
|
||||||
|
b: Array<{ type: string; value: string }>
|
||||||
|
): Array<{ type: string; value: string }> {
|
||||||
|
const seen = new Map<string, string>();
|
||||||
|
for (const e of [...a, ...b]) {
|
||||||
|
const key = `${e.type}:${e.value.toLowerCase()}`;
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.set(key, e.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(seen.entries()).map(([key, value]) => {
|
||||||
|
const [type] = key.split(":");
|
||||||
|
return { type, value };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private generateSummary(
|
||||||
|
source: string,
|
||||||
|
category: string,
|
||||||
|
title: string
|
||||||
|
): string {
|
||||||
|
return `${source} - ${category}: ${title}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getCorrelatedAlerts(
|
||||||
|
query: CorrelationQuery
|
||||||
|
): Promise<{ alerts: CorrelatedAlertOutput[]; total: number }> {
|
||||||
|
const where: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (query.userId) where.userId = query.userId;
|
||||||
|
if (query.groupId) where.groupId = query.groupId;
|
||||||
|
if (query.source) where.source = query.source;
|
||||||
|
if (query.category) where.category = query.category;
|
||||||
|
if (query.severity) where.severity = query.severity;
|
||||||
|
|
||||||
|
if (query.timeWindowMinutes) {
|
||||||
|
where.createdAt = {
|
||||||
|
gte: new Date(Date.now() - query.timeWindowMinutes * 60 * 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.entityType && query.entityId) {
|
||||||
|
where.entities = {
|
||||||
|
path: [],
|
||||||
|
contains: JSON.stringify({ type: query.entityType, value: query.entityId }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [alerts, total] = await Promise.all([
|
||||||
|
(prisma as any).normalizedAlert.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: query.limit || 50,
|
||||||
|
skip: query.offset || 0,
|
||||||
|
}),
|
||||||
|
(prisma as any).normalizedAlert.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
alerts: alerts.map((a: AlertRow) => this.toOutput(a)),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getCorrelationGroups(
|
||||||
|
query: CorrelationQuery
|
||||||
|
): Promise<{ groups: CorrelationGroupOutput[]; total: number }> {
|
||||||
|
const where: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
if (query.userId) where.userId = query.userId;
|
||||||
|
if (query.status) where.status = query.status;
|
||||||
|
|
||||||
|
if (query.timeWindowMinutes) {
|
||||||
|
where.createdAt = {
|
||||||
|
gte: new Date(Date.now() - query.timeWindowMinutes * 60 * 1000),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [groups, total] = await Promise.all([
|
||||||
|
(prisma as any).correlationGroup.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: query.limit || 50,
|
||||||
|
skip: query.offset || 0,
|
||||||
|
include: {
|
||||||
|
alerts: {
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
(prisma as any).correlationGroup.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
groups: groups.map((g: GroupRow & { alerts: AlertRow[] }) =>
|
||||||
|
this.toGroupOutput(g)
|
||||||
|
),
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getGroupById(
|
||||||
|
groupId: string
|
||||||
|
): Promise<CorrelationGroupOutput | null> {
|
||||||
|
const group = await (prisma as any).correlationGroup.findUnique({
|
||||||
|
where: { id: groupId },
|
||||||
|
include: {
|
||||||
|
alerts: {
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return group ? this.toGroupOutput(group as GroupRow & { alerts: AlertRow[] }) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async resolveGroup(
|
||||||
|
groupId: string,
|
||||||
|
status: string = CorrelationStatus.RESOLVED
|
||||||
|
): Promise<CorrelationGroupOutput | null> {
|
||||||
|
const group = await (prisma as any).correlationGroup.update({
|
||||||
|
where: { id: groupId },
|
||||||
|
data: {
|
||||||
|
status,
|
||||||
|
resolvedAt: new Date(),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
alerts: {
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.toGroupOutput(group as GroupRow & { alerts: AlertRow[] });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getDashboardData(
|
||||||
|
userId: string,
|
||||||
|
timeWindowMinutes: number = 60
|
||||||
|
): Promise<{
|
||||||
|
totalAlerts: number;
|
||||||
|
activeCorrelations: number;
|
||||||
|
alertsBySource: Record<string, number>;
|
||||||
|
alertsBySeverity: Record<string, number>;
|
||||||
|
recentGroups: CorrelationGroupOutput[];
|
||||||
|
}> {
|
||||||
|
const cutoff = new Date(Date.now() - timeWindowMinutes * 60 * 1000);
|
||||||
|
|
||||||
|
const [totalAlerts, activeCorrelations, recentGroups] = await Promise.all([
|
||||||
|
(prisma as any).normalizedAlert.count({
|
||||||
|
where: { userId, createdAt: { gte: cutoff } },
|
||||||
|
}),
|
||||||
|
(prisma as any).correlationGroup.count({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
status: CorrelationStatus.ACTIVE,
|
||||||
|
createdAt: { gte: cutoff },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
(prisma as any).correlationGroup.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
status: CorrelationStatus.ACTIVE,
|
||||||
|
createdAt: { gte: cutoff },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 10,
|
||||||
|
include: { alerts: { orderBy: { createdAt: "desc" }, take: 100 } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const alertsBySource: Record<string, number> = {};
|
||||||
|
const alertsBySeverity: Record<string, number> = {};
|
||||||
|
|
||||||
|
const recentAlerts = await (prisma as any).normalizedAlert.findMany({
|
||||||
|
where: { userId, createdAt: { gte: cutoff } },
|
||||||
|
select: { source: true, severity: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const alert of recentAlerts) {
|
||||||
|
alertsBySource[alert.source] = (alertsBySource[alert.source] || 0) + 1;
|
||||||
|
alertsBySeverity[alert.severity] = (alertsBySeverity[alert.severity] || 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalAlerts,
|
||||||
|
activeCorrelations,
|
||||||
|
alertsBySource,
|
||||||
|
alertsBySeverity,
|
||||||
|
recentGroups: recentGroups.map(
|
||||||
|
(g: GroupRow & { alerts: AlertRow[] }) => this.toGroupOutput(g)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toOutput(alert: AlertRow): CorrelatedAlertOutput {
|
||||||
|
return {
|
||||||
|
id: alert.id,
|
||||||
|
source: alert.source as AlertSource,
|
||||||
|
category: alert.category as AlertCategory,
|
||||||
|
severity: alert.severity as Severity,
|
||||||
|
userId: alert.userId,
|
||||||
|
title: alert.title,
|
||||||
|
description: alert.description,
|
||||||
|
entities: alert.entities as Array<{ type: EntityType; value: string }>,
|
||||||
|
sourceAlertId: alert.sourceAlertId,
|
||||||
|
groupId: alert.groupId || "",
|
||||||
|
payload: alert.payload as Record<string, unknown>,
|
||||||
|
createdAt: alert.createdAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toGroupOutput(
|
||||||
|
group: GroupRow & { alerts: AlertRow[] }
|
||||||
|
): CorrelationGroupOutput {
|
||||||
|
const sources = new Set<string>();
|
||||||
|
const categories = new Set<string>();
|
||||||
|
const entities = group.entities as Array<{ type: EntityType; value: string }>;
|
||||||
|
|
||||||
|
for (const alert of group.alerts) {
|
||||||
|
sources.add(alert.source);
|
||||||
|
categories.add(alert.category);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: group.id,
|
||||||
|
groupId: group.id,
|
||||||
|
alertCount: group.alertCount,
|
||||||
|
highestSeverity: group.highestSeverity as Severity,
|
||||||
|
status: group.status as CorrelationStatus,
|
||||||
|
entities,
|
||||||
|
sources: Array.from(sources) as AlertSource[],
|
||||||
|
categories: Array.from(categories) as AlertCategory[],
|
||||||
|
createdAt: group.createdAt,
|
||||||
|
updatedAt: group.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const correlationEngine = new CorrelationEngine();
|
||||||
9
packages/correlation/src/index.ts
Normal file
9
packages/correlation/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export { alertNormalizer, AlertNormalizer } from "./normalizer";
|
||||||
|
export { correlationEngine, CorrelationEngine } from "./engine";
|
||||||
|
export { correlationService, CorrelationService } from "./service";
|
||||||
|
export {
|
||||||
|
emitDarkWatchAlert,
|
||||||
|
emitSpamShieldAlert,
|
||||||
|
emitVoicePrintAlert,
|
||||||
|
emitCallAnalysisAlert,
|
||||||
|
} from "./emitter";
|
||||||
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();
|
||||||
143
packages/correlation/src/service.ts
Normal file
143
packages/correlation/src/service.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import {
|
||||||
|
AlertSource,
|
||||||
|
AlertCategory,
|
||||||
|
Severity,
|
||||||
|
EntityType,
|
||||||
|
NormalizedAlertInput,
|
||||||
|
CorrelationGroupOutput,
|
||||||
|
CorrelatedAlertOutput,
|
||||||
|
CorrelationQuery,
|
||||||
|
} from "@shieldai/types";
|
||||||
|
import { alertNormalizer, AlertNormalizer } from "./normalizer";
|
||||||
|
import { correlationEngine, CorrelationEngine } from "./engine";
|
||||||
|
|
||||||
|
export class CorrelationService {
|
||||||
|
private normalizer: AlertNormalizer;
|
||||||
|
private engine: CorrelationEngine;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
normalizer: AlertNormalizer = alertNormalizer,
|
||||||
|
engine: CorrelationEngine = correlationEngine
|
||||||
|
) {
|
||||||
|
this.normalizer = normalizer;
|
||||||
|
this.engine = engine;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ingestDarkWatchAlert(
|
||||||
|
userId: string,
|
||||||
|
sourceAlertId: string,
|
||||||
|
payload: {
|
||||||
|
exposureId: string;
|
||||||
|
breachName: string;
|
||||||
|
severity: string;
|
||||||
|
channel: string;
|
||||||
|
dataType?: string[];
|
||||||
|
dataSource?: string;
|
||||||
|
},
|
||||||
|
timestamp?: Date
|
||||||
|
): Promise<CorrelatedAlertOutput> {
|
||||||
|
const normalized = this.normalizer.normalizeDarkWatchAlert(
|
||||||
|
userId,
|
||||||
|
sourceAlertId,
|
||||||
|
payload,
|
||||||
|
timestamp
|
||||||
|
);
|
||||||
|
return this.engine.ingestAlert(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ingestSpamShieldAlert(
|
||||||
|
userId: string,
|
||||||
|
sourceAlertId: string,
|
||||||
|
payload: {
|
||||||
|
phoneNumber: string;
|
||||||
|
decision: string;
|
||||||
|
confidence: number;
|
||||||
|
reasons?: string[];
|
||||||
|
channel?: "call" | "sms";
|
||||||
|
hiyaReputationScore?: number;
|
||||||
|
truecallerSpamScore?: number;
|
||||||
|
},
|
||||||
|
timestamp?: Date
|
||||||
|
): Promise<CorrelatedAlertOutput> {
|
||||||
|
const normalized = this.normalizer.normalizeSpamShieldAlert(
|
||||||
|
userId,
|
||||||
|
sourceAlertId,
|
||||||
|
payload,
|
||||||
|
timestamp
|
||||||
|
);
|
||||||
|
return this.engine.ingestAlert(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ingestVoicePrintAlert(
|
||||||
|
userId: string,
|
||||||
|
sourceAlertId: string,
|
||||||
|
payload: {
|
||||||
|
jobId: string;
|
||||||
|
verdict: string;
|
||||||
|
syntheticScore: number;
|
||||||
|
confidence: number;
|
||||||
|
matchedEnrollmentId?: string;
|
||||||
|
matchedSimilarity?: number;
|
||||||
|
analysisType?: string;
|
||||||
|
},
|
||||||
|
timestamp?: Date
|
||||||
|
): Promise<CorrelatedAlertOutput> {
|
||||||
|
const normalized = this.normalizer.normalizeVoicePrintAlert(
|
||||||
|
userId,
|
||||||
|
sourceAlertId,
|
||||||
|
payload,
|
||||||
|
timestamp
|
||||||
|
);
|
||||||
|
return this.engine.ingestAlert(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ingestCallAnalysisAlert(
|
||||||
|
userId: string,
|
||||||
|
sourceAlertId: string,
|
||||||
|
payload: {
|
||||||
|
callId: string;
|
||||||
|
eventType?: string;
|
||||||
|
mosScore?: number;
|
||||||
|
anomaly?: string;
|
||||||
|
sentiment?: { label: string; score: number };
|
||||||
|
},
|
||||||
|
timestamp?: Date
|
||||||
|
): Promise<CorrelatedAlertOutput> {
|
||||||
|
const normalized = this.normalizer.normalizeCallAnalysisAlert(
|
||||||
|
userId,
|
||||||
|
sourceAlertId,
|
||||||
|
payload,
|
||||||
|
timestamp
|
||||||
|
);
|
||||||
|
return this.engine.ingestAlert(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ingestGenericAlert(
|
||||||
|
input: NormalizedAlertInput
|
||||||
|
): Promise<CorrelatedAlertOutput> {
|
||||||
|
return this.engine.ingestAlert(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCorrelatedAlerts(query: CorrelationQuery) {
|
||||||
|
return this.engine.getCorrelatedAlerts(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getCorrelationGroups(query: CorrelationQuery) {
|
||||||
|
return this.engine.getCorrelationGroups(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getGroupById(groupId: string) {
|
||||||
|
return this.engine.getGroupById(groupId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public resolveGroup(groupId: string, status?: string) {
|
||||||
|
return this.engine.resolveGroup(groupId, status as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
public getDashboardData(userId: string, timeWindowMinutes?: number) {
|
||||||
|
return this.engine.getDashboardData(userId, timeWindowMinutes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const correlationService = new CorrelationService();
|
||||||
|
export { alertNormalizer, correlationEngine };
|
||||||
8
packages/correlation/tsconfig.json
Normal file
8
packages/correlation/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -25,8 +25,11 @@ enum WatchListStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum Severity {
|
enum Severity {
|
||||||
|
LOW
|
||||||
INFO
|
INFO
|
||||||
|
MEDIUM
|
||||||
WARNING
|
WARNING
|
||||||
|
HIGH
|
||||||
CRITICAL
|
CRITICAL
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,10 +91,12 @@ model User {
|
|||||||
scanSchedules ScanSchedule[]
|
scanSchedules ScanSchedule[]
|
||||||
voiceEnrollments VoiceEnrollment[]
|
voiceEnrollments VoiceEnrollment[]
|
||||||
analysisJobs AnalysisJob[]
|
analysisJobs AnalysisJob[]
|
||||||
spamFeedback SpamFeedback[]
|
spamFeedback SpamFeedback[]
|
||||||
spamCallAnalyses SpamCallAnalysis[]
|
spamCallAnalyses SpamCallAnalysis[]
|
||||||
spamAuditLogs SpamAuditLog[]
|
spamAuditLogs SpamAuditLog[]
|
||||||
createdAt DateTime @default(now())
|
normalizedAlerts NormalizedAlert[]
|
||||||
|
correlationGroups CorrelationGroup[]
|
||||||
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
@@ -332,3 +337,76 @@ model SpamAuditLog {
|
|||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
@@index([decision])
|
@@index([decision])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum AlertSource {
|
||||||
|
DARKWATCH
|
||||||
|
SPAMSHIELD
|
||||||
|
VOICEPRINT
|
||||||
|
CALL_ANALYSIS
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AlertCategory {
|
||||||
|
BREACH_EXPOSURE
|
||||||
|
SPAM_CALL
|
||||||
|
SPAM_SMS
|
||||||
|
SYNTHETIC_VOICE
|
||||||
|
VOICE_MISMATCH
|
||||||
|
CALL_QUALITY
|
||||||
|
CALL_ANOMALY
|
||||||
|
CALL_EVENT
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CorrelationStatus {
|
||||||
|
ACTIVE
|
||||||
|
RESOLVED
|
||||||
|
FALSE_POSITIVE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum EntityType {
|
||||||
|
PHONE_NUMBER
|
||||||
|
EMAIL
|
||||||
|
USER_ID
|
||||||
|
CALL_ID
|
||||||
|
IP_ADDRESS
|
||||||
|
}
|
||||||
|
|
||||||
|
model NormalizedAlert {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
source AlertSource
|
||||||
|
category AlertCategory
|
||||||
|
severity Severity
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
title String
|
||||||
|
description String
|
||||||
|
entities Json // [{ type: EntityType, value: string }]
|
||||||
|
sourceAlertId String
|
||||||
|
groupId String?
|
||||||
|
correlationGroup CorrelationGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull)
|
||||||
|
payload Json
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([userId, createdAt])
|
||||||
|
@@index([groupId])
|
||||||
|
@@index([sourceAlertId])
|
||||||
|
@@index([source])
|
||||||
|
@@index([severity])
|
||||||
|
}
|
||||||
|
|
||||||
|
model CorrelationGroup {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade, map: "corr_user_idx")
|
||||||
|
entities Json // [{ type: EntityType, value: string }]
|
||||||
|
highestSeverity Severity
|
||||||
|
status CorrelationStatus @default(ACTIVE)
|
||||||
|
alertCount Int @default(0)
|
||||||
|
alerts NormalizedAlert[]
|
||||||
|
summary String?
|
||||||
|
resolvedAt DateTime?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@index([userId, status])
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
|
|||||||
28
packages/integration-tests/src/fixtures/test-db.ts
Normal file
28
packages/integration-tests/src/fixtures/test-db.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { PrismaClient } from '@shieldai/db';
|
||||||
|
|
||||||
|
let prisma: PrismaClient | null = null;
|
||||||
|
|
||||||
|
export async function initializeTestDB(): Promise<PrismaClient> {
|
||||||
|
if (!prisma) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
process.env.DATABASE_URL = process.env.DATABASE_URL || 'postgresql://test:test@localhost:5432/test';
|
||||||
|
const db = await import('@shieldai/db');
|
||||||
|
const PC = (db as unknown as { PrismaClient: new () => PrismaClient }).PrismaClient;
|
||||||
|
prisma = new PC();
|
||||||
|
}
|
||||||
|
return prisma;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupTestDB(): Promise<void> {
|
||||||
|
if (prisma) {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
prisma = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTestDB(): PrismaClient {
|
||||||
|
if (!prisma) {
|
||||||
|
throw new Error('Test database not initialized. Call initializeTestDB() first.');
|
||||||
|
}
|
||||||
|
return prisma;
|
||||||
|
}
|
||||||
@@ -10,9 +10,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bullmq": "^5.24.0",
|
"bullmq": "^5.24.0",
|
||||||
"@shieldai/db": "0.1.0",
|
"@shieldai/db": "workspace:*",
|
||||||
"@shieldai/types": "0.1.0",
|
"@shieldai/types": "workspace:*",
|
||||||
"@shieldai/darkwatch": "0.1.0",
|
"@shieldai/darkwatch": "workspace:*",
|
||||||
"ioredis": "^5.4.0"
|
"ioredis": "^5.4.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { AllDefaultTemplates, DEFAULT_LOCALE } from '../templates/default-templa
|
|||||||
|
|
||||||
const CACHE_TTL_MS = 300000;
|
const CACHE_TTL_MS = 300000;
|
||||||
const VARIABLE_PATTERN = /\{\{(\w+)\}\}/g;
|
const VARIABLE_PATTERN = /\{\{(\w+)\}\}/g;
|
||||||
const TRUSTED_DOMAINS = ['shieldai.com', 'app.shieldai.com', 'api.shieldai.com'];
|
const TRUSTED_DOMAINS = ['shieldai.com', 'shieldai.app', 'app.shieldai.com', 'api.shieldai.com'];
|
||||||
|
|
||||||
export class TemplateService {
|
export class TemplateService {
|
||||||
private static instance: TemplateService;
|
private static instance: TemplateService;
|
||||||
|
|||||||
@@ -4,6 +4,6 @@ export default defineConfig({
|
|||||||
test: {
|
test: {
|
||||||
globals: true,
|
globals: true,
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['src/**/*.test.ts'],
|
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,12 +13,106 @@ export const SubscriptionTier = {
|
|||||||
export type SubscriptionTier = (typeof SubscriptionTier)[keyof typeof SubscriptionTier];
|
export type SubscriptionTier = (typeof SubscriptionTier)[keyof typeof SubscriptionTier];
|
||||||
|
|
||||||
export const Severity = {
|
export const Severity = {
|
||||||
|
LOW: "LOW",
|
||||||
INFO: "INFO",
|
INFO: "INFO",
|
||||||
|
MEDIUM: "MEDIUM",
|
||||||
WARNING: "WARNING",
|
WARNING: "WARNING",
|
||||||
|
HIGH: "HIGH",
|
||||||
CRITICAL: "CRITICAL",
|
CRITICAL: "CRITICAL",
|
||||||
} as const;
|
} as const;
|
||||||
export type Severity = (typeof Severity)[keyof typeof Severity];
|
export type Severity = (typeof Severity)[keyof typeof Severity];
|
||||||
|
|
||||||
|
export const AlertSource = {
|
||||||
|
DARKWATCH: "DARKWATCH",
|
||||||
|
SPAMSHIELD: "SPAMSHIELD",
|
||||||
|
VOICEPRINT: "VOICEPRINT",
|
||||||
|
CALL_ANALYSIS: "CALL_ANALYSIS",
|
||||||
|
} as const;
|
||||||
|
export type AlertSource = (typeof AlertSource)[keyof typeof AlertSource];
|
||||||
|
|
||||||
|
export const AlertCategory = {
|
||||||
|
BREACH_EXPOSURE: "BREACH_EXPOSURE",
|
||||||
|
SPAM_CALL: "SPAM_CALL",
|
||||||
|
SPAM_SMS: "SPAM_SMS",
|
||||||
|
SYNTHETIC_VOICE: "SYNTHETIC_VOICE",
|
||||||
|
VOICE_MISMATCH: "VOICE_MISMATCH",
|
||||||
|
CALL_QUALITY: "CALL_QUALITY",
|
||||||
|
CALL_ANOMALY: "CALL_ANOMALY",
|
||||||
|
CALL_EVENT: "CALL_EVENT",
|
||||||
|
} as const;
|
||||||
|
export type AlertCategory = (typeof AlertCategory)[keyof typeof AlertCategory];
|
||||||
|
|
||||||
|
export const CorrelationStatus = {
|
||||||
|
ACTIVE: "ACTIVE",
|
||||||
|
RESOLVED: "RESOLVED",
|
||||||
|
FALSE_POSITIVE: "FALSE_POSITIVE",
|
||||||
|
} as const;
|
||||||
|
export type CorrelationStatus = (typeof CorrelationStatus)[keyof typeof CorrelationStatus];
|
||||||
|
|
||||||
|
export const EntityTypes = {
|
||||||
|
PHONE_NUMBER: "PHONE_NUMBER",
|
||||||
|
EMAIL: "EMAIL",
|
||||||
|
USER_ID: "USER_ID",
|
||||||
|
CALL_ID: "CALL_ID",
|
||||||
|
IP_ADDRESS: "IP_ADDRESS",
|
||||||
|
} as const;
|
||||||
|
export type EntityType = (typeof EntityTypes)[keyof typeof EntityTypes];
|
||||||
|
|
||||||
|
export interface NormalizedAlertInput {
|
||||||
|
source: AlertSource;
|
||||||
|
category: AlertCategory;
|
||||||
|
severity: Severity;
|
||||||
|
userId: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
entities: Array<{ type: EntityType; value: string }>;
|
||||||
|
sourceAlertId: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
timestamp?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CorrelationGroupOutput {
|
||||||
|
id: string;
|
||||||
|
groupId: string;
|
||||||
|
alertCount: number;
|
||||||
|
highestSeverity: Severity;
|
||||||
|
status: CorrelationStatus;
|
||||||
|
entities: Array<{ type: EntityType; value: string }>;
|
||||||
|
sources: AlertSource[];
|
||||||
|
categories: AlertCategory[];
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CorrelatedAlertOutput {
|
||||||
|
id: string;
|
||||||
|
source: AlertSource;
|
||||||
|
category: AlertCategory;
|
||||||
|
severity: Severity;
|
||||||
|
userId: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
entities: Array<{ type: EntityType; value: string }>;
|
||||||
|
sourceAlertId: string;
|
||||||
|
groupId: string;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
createdAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CorrelationQuery {
|
||||||
|
userId?: string;
|
||||||
|
groupId?: string;
|
||||||
|
source?: AlertSource;
|
||||||
|
category?: AlertCategory;
|
||||||
|
severity?: Severity;
|
||||||
|
status?: CorrelationStatus;
|
||||||
|
entityType?: EntityType;
|
||||||
|
entityId?: string;
|
||||||
|
timeWindowMinutes?: number;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const AlertChannel = {
|
export const AlertChannel = {
|
||||||
EMAIL: "EMAIL",
|
EMAIL: "EMAIL",
|
||||||
PUSH: "PUSH",
|
PUSH: "PUSH",
|
||||||
|
|||||||
6696
pnpm-lock.yaml
generated
6696
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- "packages/*"
|
||||||
|
- "services/*"
|
||||||
@@ -9,8 +9,9 @@
|
|||||||
"lint": "eslint src/"
|
"lint": "eslint src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shieldai/db": "0.1.0",
|
"@shieldai/db": "workspace:*",
|
||||||
"@shieldai/types": "0.1.0",
|
"@shieldai/types": "workspace:*",
|
||||||
|
"@shieldai/correlation": "workspace:*",
|
||||||
"node-cache": "^5.1.2"
|
"node-cache": "^5.1.2"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import prisma from "@shieldai/db";
|
import prisma from "@shieldai/db";
|
||||||
import { AlertChannel, AlertStatus, Severity } from "@shieldai/types";
|
import { AlertChannel, AlertStatus, Severity } from "@shieldai/types";
|
||||||
|
import { emitDarkWatchAlert } from "@shieldai/correlation";
|
||||||
import { createHash } from "crypto";
|
import { createHash } from "crypto";
|
||||||
import NodeCache from "node-cache";
|
import NodeCache from "node-cache";
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ export class AlertPipeline {
|
|||||||
|
|
||||||
if (existing) return false;
|
if (existing) return false;
|
||||||
|
|
||||||
await prisma.alert.create({
|
const alert = await prisma.alert.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
exposureId,
|
exposureId,
|
||||||
@@ -39,6 +40,24 @@ export class AlertPipeline {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const exposure = await prisma.exposure.findUnique({
|
||||||
|
where: { id: exposureId },
|
||||||
|
include: { watchListItem: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (exposure) {
|
||||||
|
emitDarkWatchAlert(
|
||||||
|
userId,
|
||||||
|
exposureId,
|
||||||
|
alert.id,
|
||||||
|
exposure.breachName,
|
||||||
|
severity,
|
||||||
|
channel,
|
||||||
|
exposure.dataType,
|
||||||
|
exposure.dataSource
|
||||||
|
).catch((err) => console.error(`[Correlation] DarkWatch emit failed:`, err));
|
||||||
|
}
|
||||||
|
|
||||||
this.cache.set(dedupKey, true, this.dedupWindowMs / 1000);
|
this.cache.set(dedupKey, true, this.dedupWindowMs / 1000);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,9 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shieldai/db": "0.1.0",
|
"@shieldai/db": "workspace:*",
|
||||||
"@shieldai/types": "0.1.0",
|
"@shieldai/types": "workspace:*",
|
||||||
|
"@shieldai/correlation": "workspace:*",
|
||||||
"@prisma/client": "^6.2.0",
|
"@prisma/client": "^6.2.0",
|
||||||
"libphonenumber-js": "^1.10.50",
|
"libphonenumber-js": "^1.10.50",
|
||||||
"ws": "^8.16.0"
|
"ws": "^8.16.0"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { PrismaClient, SpamFeedback, SpamRule, SpamAuditLog } from '@prisma/client';
|
import { PrismaClient, SpamFeedback, SpamRule, SpamAuditLog } from '@prisma/client';
|
||||||
import { FieldEncryptionService } from '@shieldai/db';
|
import { FieldEncryptionService } from '@shieldai/db';
|
||||||
import { generateRequestId } from '@shieldai/types';
|
import { generateRequestId } from '@shieldai/types';
|
||||||
|
import { emitSpamShieldAlert } from '@shieldai/correlation';
|
||||||
import { spamConfig, spamFeatureFlags } from '../config/spamshield.config';
|
import { spamConfig, spamFeatureFlags } from '../config/spamshield.config';
|
||||||
import { CircuitBreaker, CircuitBreakerError, CircuitState, CircuitBreakerMetrics } from '../circuit-breaker';
|
import { CircuitBreaker, CircuitBreakerError, CircuitState, CircuitBreakerMetrics } from '../circuit-breaker';
|
||||||
import { validatePhoneNumber as validateE164 } from '../utils/phone-validation';
|
import { validatePhoneNumber as validateE164 } from '../utils/phone-validation';
|
||||||
@@ -213,7 +214,7 @@ export class SpamShieldService {
|
|||||||
confidence = Math.min(confidence, 1.0);
|
confidence = Math.min(confidence, 1.0);
|
||||||
const decision = confidence > 0.8 ? 'BLOCK' : confidence > 0.5 ? 'FLAG' : 'ALLOW';
|
const decision = confidence > 0.8 ? 'BLOCK' : confidence > 0.5 ? 'FLAG' : 'ALLOW';
|
||||||
|
|
||||||
await prisma.spamAuditLog.create({
|
const auditLog = await prisma.spamAuditLog.create({
|
||||||
data: {
|
data: {
|
||||||
userId: 'system',
|
userId: 'system',
|
||||||
phoneNumber: validated,
|
phoneNumber: validated,
|
||||||
@@ -223,6 +224,17 @@ export class SpamShieldService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (decision === 'BLOCK' || decision === 'FLAG') {
|
||||||
|
emitSpamShieldAlert(
|
||||||
|
'system',
|
||||||
|
auditLog.id,
|
||||||
|
validated,
|
||||||
|
decision,
|
||||||
|
confidence,
|
||||||
|
ruleMatches
|
||||||
|
).catch((err) => console.error(`[Correlation] SpamShield emit failed:`, err));
|
||||||
|
}
|
||||||
|
|
||||||
return { decision, confidence, ruleMatches };
|
return { decision, confidence, ruleMatches };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,9 @@
|
|||||||
"lint": "eslint src/"
|
"lint": "eslint src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@shieldai/db": "0.1.0",
|
"@shieldai/db": "workspace:*",
|
||||||
"@shieldai/types": "0.1.0",
|
"@shieldai/types": "workspace:*",
|
||||||
|
"@shieldai/correlation": "workspace:*",
|
||||||
"node-cache": "^5.1.2"
|
"node-cache": "^5.1.2"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import prisma from "@shieldai/db";
|
|||||||
import { AudioPreprocessor, AudioFeatures } from "../preprocessor/AudioPreprocessor";
|
import { AudioPreprocessor, AudioFeatures } from "../preprocessor/AudioPreprocessor";
|
||||||
import { EmbeddingService, EmbeddingOutput } from "../embedding/EmbeddingService";
|
import { EmbeddingService, EmbeddingOutput } from "../embedding/EmbeddingService";
|
||||||
import { VoiceEnrollmentService } from "../enrollment/VoiceEnrollmentService";
|
import { VoiceEnrollmentService } from "../enrollment/VoiceEnrollmentService";
|
||||||
|
import { emitVoicePrintAlert } from "@shieldai/correlation";
|
||||||
import {
|
import {
|
||||||
AnalyzeAudioInput,
|
AnalyzeAudioInput,
|
||||||
AnalysisJobStatus,
|
AnalysisJobStatus,
|
||||||
@@ -78,6 +79,19 @@ export class AnalysisService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (result.verdict === DetectionVerdict.SYNTHETIC || result.verdict === DetectionVerdict.UNCERTAIN) {
|
||||||
|
emitVoicePrintAlert(
|
||||||
|
userId,
|
||||||
|
job.id,
|
||||||
|
result.verdict,
|
||||||
|
result.syntheticScore,
|
||||||
|
result.confidence,
|
||||||
|
result.matchedEnrollmentId || undefined,
|
||||||
|
result.matchedSimilarity || undefined,
|
||||||
|
input.analysisType || undefined
|
||||||
|
).catch((err) => console.error(`[Correlation] VoicePrint emit failed:`, err));
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
syntheticScore: result.syntheticScore,
|
syntheticScore: result.syntheticScore,
|
||||||
|
|||||||
Reference in New Issue
Block a user