feat: add alert correlation & normalization engine with tRPC router
Implement the cross-service alert correlation and normalization engine: - correlation router with 6 procedures: getAlerts, getAlertDetails, getGroups, getGroupDetails, resolveAlert, getStats - correlation service with normalizeAlert, correlateAlerts, getAlertTimeline, resolveAlert, getThreatScore, getAlertStats - correlation engine with findRelatedAlerts, createCorrelationGroup, updateGroupSeverity, deduplicateAlerts - alert normalizer with service-specific converters for DarkWatch, SpamShield, VoicePrint, HomeTitle, and RemoveBrokers - Entity extraction (emails, phones, SSNs) and threat scoring with severity-weighted decay over 30-day window - 52 unit tests across engine, service, normalizer, and router
This commit is contained in:
@@ -7,6 +7,7 @@ import { voiceprintRouter } from "./routers/voiceprint";
|
|||||||
import { spamshieldRouter } from "./routers/spamshield";
|
import { spamshieldRouter } from "./routers/spamshield";
|
||||||
import { hometitleRouter } from "./routers/hometitle";
|
import { hometitleRouter } from "./routers/hometitle";
|
||||||
import { removebrokersRouter } from "./routers/removebrokers";
|
import { removebrokersRouter } from "./routers/removebrokers";
|
||||||
|
import { correlationRouter } from "./routers/correlation";
|
||||||
import { createTRPCRouter } from "./utils";
|
import { createTRPCRouter } from "./utils";
|
||||||
|
|
||||||
export const appRouter = createTRPCRouter({
|
export const appRouter = createTRPCRouter({
|
||||||
@@ -19,6 +20,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
spamshield: spamshieldRouter,
|
spamshield: spamshieldRouter,
|
||||||
hometitle: hometitleRouter,
|
hometitle: hometitleRouter,
|
||||||
removebrokers: removebrokersRouter,
|
removebrokers: removebrokersRouter,
|
||||||
|
correlation: correlationRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|||||||
225
web/src/server/api/routers/correlation.test.ts
Normal file
225
web/src/server/api/routers/correlation.test.ts
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
import { initTRPC, TRPCError } from "@trpc/server";
|
||||||
|
import { wrap } from "@typeschema/valibot";
|
||||||
|
import {
|
||||||
|
AlertFilterSchema,
|
||||||
|
AlertDetailsSchema,
|
||||||
|
GroupFilterSchema,
|
||||||
|
GroupDetailsSchema,
|
||||||
|
ResolveAlertSchema,
|
||||||
|
} from "../schemas/correlation";
|
||||||
|
|
||||||
|
vi.mock("~/server/services/correlation.service", () => ({
|
||||||
|
getAlertTimeline: vi.fn(),
|
||||||
|
getAlertDetails: vi.fn(),
|
||||||
|
getCorrelationGroups: vi.fn(),
|
||||||
|
getCorrelationGroupDetails: vi.fn(),
|
||||||
|
resolveAlert: vi.fn(),
|
||||||
|
getAlertStats: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import * as correlationService from "~/server/services/correlation.service";
|
||||||
|
|
||||||
|
const mockGetAlertTimeline = vi.mocked(correlationService.getAlertTimeline);
|
||||||
|
const mockGetAlertDetails = vi.mocked(correlationService.getAlertDetails);
|
||||||
|
const mockGetCorrelationGroups = vi.mocked(correlationService.getCorrelationGroups);
|
||||||
|
const mockGetCorrelationGroupDetails = vi.mocked(correlationService.getCorrelationGroupDetails);
|
||||||
|
const mockResolveAlert = vi.mocked(correlationService.resolveAlert);
|
||||||
|
const mockGetAlertStats = vi.mocked(correlationService.getAlertStats);
|
||||||
|
|
||||||
|
type User = {
|
||||||
|
id: string; email: string; name: string | null; image: string | null;
|
||||||
|
role: string; emailVerified: Date | null; deletedAt: Date | null;
|
||||||
|
stripeCustomerId: string | null;
|
||||||
|
createdAt: Date; updatedAt: Date;
|
||||||
|
};
|
||||||
|
type Ctx = { db: object; user: User | null; apiKey: string | null };
|
||||||
|
|
||||||
|
function createCaller(user: User | null) {
|
||||||
|
const t = initTRPC.context<Ctx>().create();
|
||||||
|
const isAuthed = t.middleware(({ ctx, next }) => {
|
||||||
|
if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED" });
|
||||||
|
return next({ ctx: { ...ctx, user: ctx.user } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const router = t.router({
|
||||||
|
getAlerts: t.procedure.use(isAuthed)
|
||||||
|
.input(wrap(AlertFilterSchema))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return mockGetAlertTimeline(ctx.user.id, input);
|
||||||
|
}),
|
||||||
|
getAlertDetails: t.procedure.use(isAuthed)
|
||||||
|
.input(wrap(AlertDetailsSchema))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return mockGetAlertDetails(ctx.user.id, input.alertId);
|
||||||
|
}),
|
||||||
|
getGroups: t.procedure.use(isAuthed)
|
||||||
|
.input(wrap(GroupFilterSchema))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return mockGetCorrelationGroups(ctx.user.id, input);
|
||||||
|
}),
|
||||||
|
getGroupDetails: t.procedure.use(isAuthed)
|
||||||
|
.input(wrap(GroupDetailsSchema))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return mockGetCorrelationGroupDetails(ctx.user.id, input.groupId);
|
||||||
|
}),
|
||||||
|
resolveAlert: t.procedure.use(isAuthed)
|
||||||
|
.input(wrap(ResolveAlertSchema))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
return mockResolveAlert(ctx.user.id, input.alertId, input.resolution);
|
||||||
|
}),
|
||||||
|
getStats: t.procedure.use(isAuthed).query(async ({ ctx }) => {
|
||||||
|
return mockGetAlertStats(ctx.user.id);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const caller = t.createCallerFactory(router);
|
||||||
|
return caller({ db: {} as never, user, apiKey: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseUser: User = {
|
||||||
|
id: "user-1", email: "a@b.com", name: "Test", image: null,
|
||||||
|
role: "user", emailVerified: null, deletedAt: null,
|
||||||
|
stripeCustomerId: null,
|
||||||
|
createdAt: new Date(), updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function makeUser(overrides: Partial<User> = {}): User {
|
||||||
|
return { ...baseUser, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("correlation.getAlerts", () => {
|
||||||
|
it("returns alert timeline for authenticated user", async () => {
|
||||||
|
const data = {
|
||||||
|
items: [{ id: "a1", source: "DARKWATCH", severity: "HIGH" }],
|
||||||
|
total: 1, page: 1, limit: 20, totalPages: 1,
|
||||||
|
};
|
||||||
|
mockGetAlertTimeline.mockResolvedValue(data as never);
|
||||||
|
const api = createCaller(makeUser());
|
||||||
|
const result = await api.getAlerts({});
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.total).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects unauthenticated requests", async () => {
|
||||||
|
const api = createCaller(null);
|
||||||
|
await expect(api.getAlerts({})).rejects.toThrow(TRPCError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("passes filters to service", async () => {
|
||||||
|
mockGetAlertTimeline.mockResolvedValue({
|
||||||
|
items: [], total: 0, page: 1, limit: 20, totalPages: 0,
|
||||||
|
} as never);
|
||||||
|
const api = createCaller(makeUser());
|
||||||
|
await api.getAlerts({ source: "DARKWATCH", severity: "HIGH", page: 1, limit: 10 });
|
||||||
|
expect(mockGetAlertTimeline).toHaveBeenCalledWith(
|
||||||
|
"user-1",
|
||||||
|
{ source: "DARKWATCH", severity: "HIGH", page: 1, limit: 10 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid source value", async () => {
|
||||||
|
const api = createCaller(makeUser());
|
||||||
|
await expect(
|
||||||
|
api.getAlerts({ source: "INVALID" as never }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("correlation.getAlertDetails", () => {
|
||||||
|
it("returns alert details", async () => {
|
||||||
|
const data = { alert: { id: "a1" }, group: null, relatedAlerts: [] };
|
||||||
|
mockGetAlertDetails.mockResolvedValue(data as never);
|
||||||
|
const api = createCaller(makeUser());
|
||||||
|
const result = await api.getAlertDetails({ alertId: "a1" });
|
||||||
|
expect(result.alert.id).toBe("a1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("correlation.getGroups", () => {
|
||||||
|
it("returns correlation groups", async () => {
|
||||||
|
const data = {
|
||||||
|
items: [{ id: "g1", highestSeverity: "CRITICAL" }],
|
||||||
|
total: 1, page: 1, limit: 20, totalPages: 1,
|
||||||
|
};
|
||||||
|
mockGetCorrelationGroups.mockResolvedValue(data as never);
|
||||||
|
const api = createCaller(makeUser());
|
||||||
|
const result = await api.getGroups({});
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters by status", async () => {
|
||||||
|
mockGetCorrelationGroups.mockResolvedValue({
|
||||||
|
items: [], total: 0, page: 1, limit: 20, totalPages: 0,
|
||||||
|
} as never);
|
||||||
|
const api = createCaller(makeUser());
|
||||||
|
await api.getGroups({ status: "ACTIVE" });
|
||||||
|
expect(mockGetCorrelationGroups).toHaveBeenCalledWith(
|
||||||
|
"user-1",
|
||||||
|
{ status: "ACTIVE", page: 1, limit: 20 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("correlation.getGroupDetails", () => {
|
||||||
|
it("returns group details with alerts", async () => {
|
||||||
|
const data = {
|
||||||
|
group: { id: "g1", highestSeverity: "HIGH" },
|
||||||
|
alerts: [{ id: "a1" }, { id: "a2" }],
|
||||||
|
};
|
||||||
|
mockGetCorrelationGroupDetails.mockResolvedValue(data as never);
|
||||||
|
const api = createCaller(makeUser());
|
||||||
|
const result = await api.getGroupDetails({ groupId: "g1" });
|
||||||
|
expect(result.group.id).toBe("g1");
|
||||||
|
expect(result.alerts).toHaveLength(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("correlation.resolveAlert", () => {
|
||||||
|
it("resolves an alert", async () => {
|
||||||
|
const data = { id: "g1", status: "RESOLVED" };
|
||||||
|
mockResolveAlert.mockResolvedValue(data as never);
|
||||||
|
const api = createCaller(makeUser());
|
||||||
|
const result = await api.resolveAlert({ alertId: "a1", resolution: "RESOLVED" });
|
||||||
|
expect(result.status).toBe("RESOLVED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks as false positive", async () => {
|
||||||
|
const data = { id: "g1", status: "FALSE_POSITIVE" };
|
||||||
|
mockResolveAlert.mockResolvedValue(data as never);
|
||||||
|
const api = createCaller(makeUser());
|
||||||
|
const result = await api.resolveAlert({ alertId: "a1", resolution: "FALSE_POSITIVE" });
|
||||||
|
expect(result.status).toBe("FALSE_POSITIVE");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid resolution", async () => {
|
||||||
|
const api = createCaller(makeUser());
|
||||||
|
await expect(
|
||||||
|
api.resolveAlert({ alertId: "a1", resolution: "INVALID" as never }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("correlation.getStats", () => {
|
||||||
|
it("returns alert statistics", async () => {
|
||||||
|
const stats = {
|
||||||
|
totalAlerts: 10,
|
||||||
|
bySeverity: { HIGH: 5, LOW: 5 },
|
||||||
|
bySource: { DARKWATCH: 10 },
|
||||||
|
activeGroups: 2,
|
||||||
|
resolvedCount: 1,
|
||||||
|
falsePositiveCount: 0,
|
||||||
|
threatScore: 45,
|
||||||
|
threatBreakdown: [{ source: "DARKWATCH", score: 45 }],
|
||||||
|
};
|
||||||
|
mockGetAlertStats.mockResolvedValue(stats as never);
|
||||||
|
const api = createCaller(makeUser());
|
||||||
|
const result = await api.getStats();
|
||||||
|
expect(result.totalAlerts).toBe(10);
|
||||||
|
expect(result.threatScore).toBe(45);
|
||||||
|
});
|
||||||
|
});
|
||||||
46
web/src/server/api/routers/correlation.ts
Normal file
46
web/src/server/api/routers/correlation.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { wrap } from "@typeschema/valibot";
|
||||||
|
import { createTRPCRouter, protectedProcedure } from "../utils";
|
||||||
|
import {
|
||||||
|
AlertFilterSchema,
|
||||||
|
AlertDetailsSchema,
|
||||||
|
GroupFilterSchema,
|
||||||
|
GroupDetailsSchema,
|
||||||
|
ResolveAlertSchema,
|
||||||
|
} from "../schemas/correlation";
|
||||||
|
import * as correlationService from "~/server/services/correlation.service";
|
||||||
|
|
||||||
|
export const correlationRouter = createTRPCRouter({
|
||||||
|
getAlerts: protectedProcedure
|
||||||
|
.input(wrap(AlertFilterSchema))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return correlationService.getAlertTimeline(ctx.user.id, input);
|
||||||
|
}),
|
||||||
|
|
||||||
|
getAlertDetails: protectedProcedure
|
||||||
|
.input(wrap(AlertDetailsSchema))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return correlationService.getAlertDetails(ctx.user.id, input.alertId);
|
||||||
|
}),
|
||||||
|
|
||||||
|
getGroups: protectedProcedure
|
||||||
|
.input(wrap(GroupFilterSchema))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return correlationService.getCorrelationGroups(ctx.user.id, input);
|
||||||
|
}),
|
||||||
|
|
||||||
|
getGroupDetails: protectedProcedure
|
||||||
|
.input(wrap(GroupDetailsSchema))
|
||||||
|
.query(async ({ ctx, input }) => {
|
||||||
|
return correlationService.getCorrelationGroupDetails(ctx.user.id, input.groupId);
|
||||||
|
}),
|
||||||
|
|
||||||
|
resolveAlert: protectedProcedure
|
||||||
|
.input(wrap(ResolveAlertSchema))
|
||||||
|
.mutation(async ({ ctx, input }) => {
|
||||||
|
return correlationService.resolveAlert(ctx.user.id, input.alertId, input.resolution);
|
||||||
|
}),
|
||||||
|
|
||||||
|
getStats: protectedProcedure.query(async ({ ctx }) => {
|
||||||
|
return correlationService.getAlertStats(ctx.user.id);
|
||||||
|
}),
|
||||||
|
});
|
||||||
36
web/src/server/api/schemas/correlation.ts
Normal file
36
web/src/server/api/schemas/correlation.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { object, string, minLength, optional, number, picklist } from "valibot";
|
||||||
|
|
||||||
|
export const AlertFilterSchema = object({
|
||||||
|
source: optional(
|
||||||
|
picklist(["DARKWATCH", "SPAMSHIELD", "VOICEPRINT", "CALL_ANALYSIS", "HOME_TITLE", "INFO_BROKER"]),
|
||||||
|
),
|
||||||
|
severity: optional(
|
||||||
|
picklist(["LOW", "INFO", "MEDIUM", "WARNING", "HIGH", "CRITICAL"]),
|
||||||
|
),
|
||||||
|
status: optional(
|
||||||
|
picklist(["ACTIVE", "RESOLVED", "FALSE_POSITIVE"]),
|
||||||
|
),
|
||||||
|
page: optional(number(), 1),
|
||||||
|
limit: optional(number(), 20),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AlertDetailsSchema = object({
|
||||||
|
alertId: string([minLength(1)]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GroupFilterSchema = object({
|
||||||
|
status: optional(
|
||||||
|
picklist(["ACTIVE", "RESOLVED", "FALSE_POSITIVE"]),
|
||||||
|
),
|
||||||
|
page: optional(number(), 1),
|
||||||
|
limit: optional(number(), 20),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const GroupDetailsSchema = object({
|
||||||
|
groupId: string([minLength(1)]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const ResolveAlertSchema = object({
|
||||||
|
alertId: string([minLength(1)]),
|
||||||
|
resolution: picklist(["RESOLVED", "FALSE_POSITIVE"]),
|
||||||
|
});
|
||||||
185
web/src/server/services/correlation.service.test.ts
Normal file
185
web/src/server/services/correlation.service.test.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const mockSelect = vi.fn();
|
||||||
|
const mockInsert = vi.fn();
|
||||||
|
const mockUpdate = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("~/server/db", () => ({
|
||||||
|
db: {
|
||||||
|
select: mockSelect,
|
||||||
|
insert: mockInsert,
|
||||||
|
update: mockUpdate,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("~/server/db/schema", () => ({
|
||||||
|
normalizedAlerts: {},
|
||||||
|
correlationGroups: {},
|
||||||
|
auditLogs: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getThreatScore", () => {
|
||||||
|
function makeSelectChain(data: unknown) {
|
||||||
|
const where = vi.fn().mockResolvedValue(data);
|
||||||
|
const from = vi.fn().mockReturnValue({ where });
|
||||||
|
mockSelect.mockReturnValue({ from });
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysAgo(n: number): Date {
|
||||||
|
return new Date(Date.now() - n * 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
it("returns score 0 with no alerts", async () => {
|
||||||
|
makeSelectChain([]);
|
||||||
|
|
||||||
|
const { getThreatScore } = await import("./correlation.service");
|
||||||
|
const result = await getThreatScore("user-1");
|
||||||
|
expect(result.score).toBe(0);
|
||||||
|
expect(result.breakdown).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns higher score for more severe alerts", async () => {
|
||||||
|
const highAlert = {
|
||||||
|
id: "a1",
|
||||||
|
severity: "CRITICAL",
|
||||||
|
source: "DARKWATCH",
|
||||||
|
createdAt: daysAgo(1),
|
||||||
|
};
|
||||||
|
makeSelectChain([highAlert]);
|
||||||
|
|
||||||
|
const { getThreatScore } = await import("./correlation.service");
|
||||||
|
const result = await getThreatScore("user-1");
|
||||||
|
expect(result.score).toBeGreaterThan(0);
|
||||||
|
expect(result.breakdown[0].source).toBe("DARKWATCH");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns lower score for less severe alerts", async () => {
|
||||||
|
const lowAlert = {
|
||||||
|
id: "a1",
|
||||||
|
severity: "LOW",
|
||||||
|
source: "DARKWATCH",
|
||||||
|
createdAt: daysAgo(1),
|
||||||
|
};
|
||||||
|
makeSelectChain([lowAlert]);
|
||||||
|
|
||||||
|
const { getThreatScore } = await import("./correlation.service");
|
||||||
|
const result = await getThreatScore("user-1");
|
||||||
|
expect(result.score).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assigns higher weight to CRITICAL than LOW", async () => {
|
||||||
|
const { getThreatScore: getScore } = await import("./correlation.service");
|
||||||
|
|
||||||
|
makeSelectChain([{
|
||||||
|
id: "a1",
|
||||||
|
severity: "CRITICAL",
|
||||||
|
source: "DARKWATCH",
|
||||||
|
createdAt: daysAgo(1),
|
||||||
|
}]);
|
||||||
|
const highResult = await getScore("user-1");
|
||||||
|
|
||||||
|
makeSelectChain([{
|
||||||
|
id: "a2",
|
||||||
|
severity: "LOW",
|
||||||
|
source: "DARKWATCH",
|
||||||
|
createdAt: daysAgo(1),
|
||||||
|
}]);
|
||||||
|
const lowResult = await getScore("user-1");
|
||||||
|
|
||||||
|
expect(highResult.score).toBeGreaterThan(lowResult.score);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores alerts older than 30 days", async () => {
|
||||||
|
makeSelectChain([]);
|
||||||
|
|
||||||
|
const { getThreatScore } = await import("./correlation.service");
|
||||||
|
const result = await getThreatScore("user-1");
|
||||||
|
expect(result.score).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("provides breakdown by source", async () => {
|
||||||
|
const alerts = [
|
||||||
|
{ id: "a1", severity: "HIGH", source: "DARKWATCH", createdAt: daysAgo(1) },
|
||||||
|
{ id: "a2", severity: "WARNING", source: "SPAMSHIELD", createdAt: daysAgo(1) },
|
||||||
|
];
|
||||||
|
makeSelectChain(alerts);
|
||||||
|
|
||||||
|
const { getThreatScore } = await import("./correlation.service");
|
||||||
|
const result = await getThreatScore("user-1");
|
||||||
|
expect(result.breakdown.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAlertTimeline", () => {
|
||||||
|
it("returns paginated alerts for user", async () => {
|
||||||
|
const countWhere = vi.fn().mockResolvedValue([{ count: 0 }]);
|
||||||
|
const countFrom = vi.fn().mockReturnValue({ where: countWhere });
|
||||||
|
mockSelect.mockReturnValueOnce({ from: countFrom });
|
||||||
|
|
||||||
|
const offset = vi.fn().mockResolvedValue([]);
|
||||||
|
const limit = vi.fn().mockReturnValue({ offset });
|
||||||
|
const orderBy = vi.fn().mockReturnValue({ limit });
|
||||||
|
const dataWhere = vi.fn().mockReturnValue({ orderBy });
|
||||||
|
const dataFrom = vi.fn().mockReturnValue({ where: dataWhere });
|
||||||
|
mockSelect.mockReturnValue({ from: dataFrom });
|
||||||
|
|
||||||
|
const { getAlertTimeline } = await import("./correlation.service");
|
||||||
|
const result = await getAlertTimeline("user-1", { page: 1, limit: 20 });
|
||||||
|
expect(result.page).toBe(1);
|
||||||
|
expect(result.limit).toBe(20);
|
||||||
|
expect(result.total).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveAlert", () => {
|
||||||
|
it("creates a group for ungrouped alert and resolves it", async () => {
|
||||||
|
const selectLimit = vi.fn().mockResolvedValue([{
|
||||||
|
id: "a1",
|
||||||
|
userId: "user-1",
|
||||||
|
source: "DARKWATCH",
|
||||||
|
severity: "HIGH",
|
||||||
|
entities: { emails: [], phones: [], ssns: [] },
|
||||||
|
title: "Test",
|
||||||
|
}]);
|
||||||
|
const selectWhere = vi.fn().mockReturnValue({ limit: selectLimit });
|
||||||
|
const selectFrom = vi.fn().mockReturnValue({ where: selectWhere });
|
||||||
|
mockSelect.mockReturnValue({ from: selectFrom });
|
||||||
|
|
||||||
|
const insertGroupReturning = vi.fn().mockResolvedValue([{
|
||||||
|
id: "new-group",
|
||||||
|
userId: "user-1",
|
||||||
|
status: "ACTIVE",
|
||||||
|
entities: { emails: [], phones: [], ssns: [] },
|
||||||
|
highestSeverity: "HIGH",
|
||||||
|
alertCount: 1,
|
||||||
|
}]);
|
||||||
|
const insertGroupValues = vi.fn().mockReturnValue({ returning: insertGroupReturning });
|
||||||
|
mockInsert.mockReturnValueOnce({ values: insertGroupValues });
|
||||||
|
|
||||||
|
const updateAlertReturning = vi.fn().mockResolvedValue([{ id: "a1", groupId: "new-group" }]);
|
||||||
|
const updateAlertWhere = vi.fn().mockReturnValue({ returning: updateAlertReturning });
|
||||||
|
const updateAlertSet = vi.fn().mockReturnValue({ where: updateAlertWhere });
|
||||||
|
mockUpdate.mockReturnValueOnce({ set: updateAlertSet });
|
||||||
|
|
||||||
|
const updateGroupReturning = vi.fn().mockResolvedValue([{
|
||||||
|
id: "new-group",
|
||||||
|
status: "RESOLVED",
|
||||||
|
resolvedAt: new Date(),
|
||||||
|
}]);
|
||||||
|
const updateGroupWhere = vi.fn().mockReturnValue({ returning: updateGroupReturning });
|
||||||
|
const updateGroupSet = vi.fn().mockReturnValue({ where: updateGroupWhere });
|
||||||
|
mockUpdate.mockReturnValueOnce({ set: updateGroupSet });
|
||||||
|
|
||||||
|
const auditReturning = vi.fn().mockResolvedValue([{}]);
|
||||||
|
const auditValues = vi.fn().mockReturnValue({ returning: auditReturning });
|
||||||
|
mockInsert.mockReturnValue({ values: auditValues });
|
||||||
|
|
||||||
|
const { resolveAlert } = await import("./correlation.service");
|
||||||
|
const result = await resolveAlert("user-1", "a1", "RESOLVED");
|
||||||
|
expect(result.status).toBe("RESOLVED");
|
||||||
|
});
|
||||||
|
});
|
||||||
472
web/src/server/services/correlation.service.ts
Normal file
472
web/src/server/services/correlation.service.ts
Normal file
@@ -0,0 +1,472 @@
|
|||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { and, desc, eq, count, gte, inArray, sql, lte } from "drizzle-orm";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { normalizedAlerts, correlationGroups, auditLogs } from "~/server/db/schema";
|
||||||
|
import {
|
||||||
|
findRelatedAlerts,
|
||||||
|
createCorrelationGroup,
|
||||||
|
updateGroupSeverity,
|
||||||
|
deduplicateAlerts,
|
||||||
|
} from "./correlation/engine";
|
||||||
|
import type { NormalizedAlertInput, EntitySet } from "./correlation/normalizer";
|
||||||
|
|
||||||
|
const SEVERITY_WEIGHTS: Record<string, number> = {
|
||||||
|
CRITICAL: 40,
|
||||||
|
HIGH: 25,
|
||||||
|
WARNING: 15,
|
||||||
|
MEDIUM: 10,
|
||||||
|
INFO: 5,
|
||||||
|
LOW: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function ensureGroupForAlert(alertId: string, userId: string): Promise<string> {
|
||||||
|
const [alert] = await db
|
||||||
|
.select()
|
||||||
|
.from(normalizedAlerts)
|
||||||
|
.where(eq(normalizedAlerts.id, alertId))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!alert) throw new TRPCError({ code: "NOT_FOUND", message: "Alert not found" });
|
||||||
|
|
||||||
|
if (alert.groupId) return alert.groupId;
|
||||||
|
|
||||||
|
const [group] = await db
|
||||||
|
.insert(correlationGroups)
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
entities: alert.entities as Record<string, unknown>,
|
||||||
|
highestSeverity: alert.severity as "LOW" | "INFO" | "MEDIUM" | "WARNING" | "HIGH" | "CRITICAL",
|
||||||
|
alertCount: 1,
|
||||||
|
summary: alert.title,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(normalizedAlerts)
|
||||||
|
.set({ groupId: group.id })
|
||||||
|
.where(eq(normalizedAlerts.id, alertId));
|
||||||
|
|
||||||
|
return group.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function normalizeAlert(
|
||||||
|
source: NormalizedAlertInput["source"],
|
||||||
|
sourceAlertId: string,
|
||||||
|
category: NormalizedAlertInput["category"],
|
||||||
|
severity: NormalizedAlertInput["severity"],
|
||||||
|
userId: string,
|
||||||
|
title: string,
|
||||||
|
description: string,
|
||||||
|
entities: EntitySet,
|
||||||
|
payload?: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
const [deduped] = await deduplicateAlerts([
|
||||||
|
{ source, sourceAlertId, category, severity, title, description, entities, payload } as NormalizedAlertInput,
|
||||||
|
]);
|
||||||
|
if (!deduped) return null;
|
||||||
|
|
||||||
|
const [alert] = await db
|
||||||
|
.insert(normalizedAlerts)
|
||||||
|
.values({
|
||||||
|
source,
|
||||||
|
sourceAlertId,
|
||||||
|
category,
|
||||||
|
severity,
|
||||||
|
userId,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
entities: entities as unknown as Record<string, unknown>,
|
||||||
|
payload: payload ?? null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return alert;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function correlateAlerts(userId: string): Promise<void> {
|
||||||
|
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const alerts = await db
|
||||||
|
.select()
|
||||||
|
.from(normalizedAlerts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(normalizedAlerts.userId, userId),
|
||||||
|
gte(normalizedAlerts.createdAt, thirtyDaysAgo),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const grouped = new Set<string>();
|
||||||
|
for (let i = 0; i < alerts.length; i++) {
|
||||||
|
if (grouped.has(alerts[i].id)) continue;
|
||||||
|
|
||||||
|
const entityA = alerts[i].entities as EntitySet;
|
||||||
|
const related = alerts.filter((a, j) => {
|
||||||
|
if (i === j || grouped.has(a.id)) return false;
|
||||||
|
if (a.groupId && a.groupId === alerts[i].groupId) return false;
|
||||||
|
return entitiesOverlap(entityA, a.entities as EntitySet);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (related.length === 0) continue;
|
||||||
|
|
||||||
|
const groupAlerts = [alerts[i], ...related];
|
||||||
|
for (const a of groupAlerts) grouped.add(a.id);
|
||||||
|
|
||||||
|
const existingGroupId = groupAlerts.find((a) => a.groupId)?.groupId;
|
||||||
|
if (existingGroupId) {
|
||||||
|
const ungrouped = groupAlerts.filter((a) => !a.groupId || a.groupId !== existingGroupId);
|
||||||
|
if (ungrouped.length > 0) {
|
||||||
|
const ungroupedIds = ungrouped.map((a) => a.id);
|
||||||
|
await db
|
||||||
|
.update(normalizedAlerts)
|
||||||
|
.set({ groupId: existingGroupId })
|
||||||
|
.where(inArray(normalizedAlerts.id, ungroupedIds));
|
||||||
|
}
|
||||||
|
await updateGroupSeverity(existingGroupId);
|
||||||
|
} else {
|
||||||
|
const mergedEntities = mergeEntities(groupAlerts.map((a) => a.entities as EntitySet));
|
||||||
|
const group = await createCorrelationGroup(groupAlerts, userId, mergedEntities);
|
||||||
|
for (const a of groupAlerts) grouped.add(a.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function entitiesOverlap(a: EntitySet, b: EntitySet): boolean {
|
||||||
|
const aSet = new Set([...a.emails, ...a.phones, ...a.ssns]);
|
||||||
|
for (const val of [...b.emails, ...b.phones, ...b.ssns]) {
|
||||||
|
if (aSet.has(val)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeEntities(entitySets: EntitySet[]): EntitySet {
|
||||||
|
const emails = [...new Set(entitySets.flatMap((e) => e.emails))];
|
||||||
|
const phones = [...new Set(entitySets.flatMap((e) => e.phones))];
|
||||||
|
const ssns = [...new Set(entitySets.flatMap((e) => e.ssns))];
|
||||||
|
return { emails, phones, ssns };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineFilter {
|
||||||
|
source?: string;
|
||||||
|
severity?: string;
|
||||||
|
status?: string;
|
||||||
|
page?: number;
|
||||||
|
limit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAlertTimeline(
|
||||||
|
userId: string,
|
||||||
|
filters: TimelineFilter = {},
|
||||||
|
): Promise<{
|
||||||
|
items: Array<Record<string, unknown>>;
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
totalPages: number;
|
||||||
|
}> {
|
||||||
|
const page = filters.page ?? 1;
|
||||||
|
const limit = filters.limit ?? 20;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const conditions = [eq(normalizedAlerts.userId, userId)];
|
||||||
|
if (filters.source) {
|
||||||
|
conditions.push(eq(normalizedAlerts.source, filters.source as typeof normalizedAlerts.$inferSelect.source));
|
||||||
|
}
|
||||||
|
if (filters.severity) {
|
||||||
|
conditions.push(eq(normalizedAlerts.severity, filters.severity as typeof normalizedAlerts.$inferSelect.severity));
|
||||||
|
}
|
||||||
|
|
||||||
|
let groupStatusCondition = undefined;
|
||||||
|
if (filters.status) {
|
||||||
|
groupStatusCondition = filters.status;
|
||||||
|
}
|
||||||
|
|
||||||
|
let items: Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
if (groupStatusCondition) {
|
||||||
|
const [totalResult] = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(normalizedAlerts)
|
||||||
|
.leftJoin(correlationGroups, eq(normalizedAlerts.groupId, correlationGroups.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
...conditions,
|
||||||
|
eq(correlationGroups.status, groupStatusCondition as "ACTIVE" | "RESOLVED" | "FALSE_POSITIVE"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(normalizedAlerts)
|
||||||
|
.leftJoin(correlationGroups, eq(normalizedAlerts.groupId, correlationGroups.id))
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
...conditions,
|
||||||
|
eq(correlationGroups.status, groupStatusCondition as "ACTIVE" | "RESOLVED" | "FALSE_POSITIVE"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.orderBy(desc(normalizedAlerts.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
items = rows.map((r) => ({
|
||||||
|
...r.normalized_alerts,
|
||||||
|
groupStatus: r.correlation_groups?.status ?? "ACTIVE",
|
||||||
|
group: r.correlation_groups ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
total: totalResult.count,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(totalResult.count / limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [totalResult] = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(normalizedAlerts)
|
||||||
|
.where(and(...conditions));
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(normalizedAlerts)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(desc(normalizedAlerts.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
items = rows as unknown as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
total: totalResult.count,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(totalResult.count / limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAlertDetails(userId: string, alertId: string) {
|
||||||
|
const [alert] = await db
|
||||||
|
.select()
|
||||||
|
.from(normalizedAlerts)
|
||||||
|
.where(and(eq(normalizedAlerts.id, alertId), eq(normalizedAlerts.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!alert) throw new TRPCError({ code: "NOT_FOUND", message: "Alert not found" });
|
||||||
|
|
||||||
|
let group = null;
|
||||||
|
let relatedAlerts: Array<typeof normalizedAlerts.$inferSelect> = [];
|
||||||
|
|
||||||
|
if (alert.groupId) {
|
||||||
|
const [foundGroup] = await db
|
||||||
|
.select()
|
||||||
|
.from(correlationGroups)
|
||||||
|
.where(eq(correlationGroups.id, alert.groupId))
|
||||||
|
.limit(1);
|
||||||
|
group = foundGroup ?? null;
|
||||||
|
|
||||||
|
relatedAlerts = await db
|
||||||
|
.select()
|
||||||
|
.from(normalizedAlerts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(normalizedAlerts.groupId, alert.groupId),
|
||||||
|
eq(normalizedAlerts.userId, userId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { alert, group, relatedAlerts };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCorrelationGroups(
|
||||||
|
userId: string,
|
||||||
|
filters: { status?: string; page?: number; limit?: number } = {},
|
||||||
|
) {
|
||||||
|
const page = filters.page ?? 1;
|
||||||
|
const limit = filters.limit ?? 20;
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
const conditions = [eq(correlationGroups.userId, userId)];
|
||||||
|
if (filters.status) {
|
||||||
|
conditions.push(eq(correlationGroups.status, filters.status as "ACTIVE" | "RESOLVED" | "FALSE_POSITIVE"));
|
||||||
|
}
|
||||||
|
|
||||||
|
const [totalResult] = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(correlationGroups)
|
||||||
|
.where(and(...conditions));
|
||||||
|
|
||||||
|
const items = await db
|
||||||
|
.select()
|
||||||
|
.from(correlationGroups)
|
||||||
|
.where(and(...conditions))
|
||||||
|
.orderBy(desc(correlationGroups.createdAt))
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
total: totalResult.count,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
totalPages: Math.ceil(totalResult.count / limit),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCorrelationGroupDetails(userId: string, groupId: string) {
|
||||||
|
const [group] = await db
|
||||||
|
.select()
|
||||||
|
.from(correlationGroups)
|
||||||
|
.where(and(eq(correlationGroups.id, groupId), eq(correlationGroups.userId, userId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!group) throw new TRPCError({ code: "NOT_FOUND", message: "Group not found" });
|
||||||
|
|
||||||
|
const alerts = await db
|
||||||
|
.select()
|
||||||
|
.from(normalizedAlerts)
|
||||||
|
.where(eq(normalizedAlerts.groupId, groupId))
|
||||||
|
.orderBy(desc(normalizedAlerts.createdAt));
|
||||||
|
|
||||||
|
return { group, alerts };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveAlert(
|
||||||
|
userId: string,
|
||||||
|
alertId: string,
|
||||||
|
resolution: "RESOLVED" | "FALSE_POSITIVE",
|
||||||
|
) {
|
||||||
|
const groupId = await ensureGroupForAlert(alertId, userId);
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(correlationGroups)
|
||||||
|
.set({
|
||||||
|
status: resolution as "ACTIVE" | "RESOLVED" | "FALSE_POSITIVE",
|
||||||
|
resolvedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(and(eq(correlationGroups.id, groupId), eq(correlationGroups.userId, userId)))
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (!updated) throw new TRPCError({ code: "NOT_FOUND", message: "Group not found" });
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(auditLogs)
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
action: "alert_resolve",
|
||||||
|
resource: "normalized_alert",
|
||||||
|
resourceId: alertId,
|
||||||
|
changes: { resolution, groupId },
|
||||||
|
metadata: { source: "correlation_router" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getThreatScore(userId: string): Promise<{
|
||||||
|
score: number;
|
||||||
|
breakdown: Array<{ source: string; score: number }>;
|
||||||
|
}> {
|
||||||
|
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const alerts = await db
|
||||||
|
.select()
|
||||||
|
.from(normalizedAlerts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(normalizedAlerts.userId, userId),
|
||||||
|
gte(normalizedAlerts.createdAt, thirtyDaysAgo),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
let totalScore = 0;
|
||||||
|
const sourceScores: Record<string, number> = {};
|
||||||
|
|
||||||
|
for (const alert of alerts) {
|
||||||
|
const weight = SEVERITY_WEIGHTS[alert.severity] ?? 1;
|
||||||
|
const ageDays = (now - alert.createdAt.getTime()) / (1000 * 60 * 60 * 24);
|
||||||
|
const decay = Math.exp(-ageDays / 30);
|
||||||
|
const contribution = weight * decay;
|
||||||
|
|
||||||
|
totalScore += contribution;
|
||||||
|
sourceScores[alert.source] = (sourceScores[alert.source] ?? 0) + contribution;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalScore = Math.min(100, Math.round(totalScore));
|
||||||
|
const breakdown = Object.entries(sourceScores).map(([source, score]) => ({
|
||||||
|
source,
|
||||||
|
score: Math.round(score * 10) / 10,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { score: finalScore, breakdown };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getAlertStats(userId: string) {
|
||||||
|
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
const [totalResult] = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(normalizedAlerts)
|
||||||
|
.where(and(eq(normalizedAlerts.userId, userId), gte(normalizedAlerts.createdAt, thirtyDaysAgo)));
|
||||||
|
|
||||||
|
const bySeverity = await db
|
||||||
|
.select({ severity: normalizedAlerts.severity, count: count() })
|
||||||
|
.from(normalizedAlerts)
|
||||||
|
.where(and(eq(normalizedAlerts.userId, userId), gte(normalizedAlerts.createdAt, thirtyDaysAgo)))
|
||||||
|
.groupBy(normalizedAlerts.severity);
|
||||||
|
|
||||||
|
const bySource = await db
|
||||||
|
.select({ source: normalizedAlerts.source, count: count() })
|
||||||
|
.from(normalizedAlerts)
|
||||||
|
.where(and(eq(normalizedAlerts.userId, userId), gte(normalizedAlerts.createdAt, thirtyDaysAgo)))
|
||||||
|
.groupBy(normalizedAlerts.source);
|
||||||
|
|
||||||
|
const [activeGroupsResult] = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(correlationGroups)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(correlationGroups.userId, userId),
|
||||||
|
eq(correlationGroups.status, "ACTIVE"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [resolvedResult] = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(correlationGroups)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(correlationGroups.userId, userId),
|
||||||
|
eq(correlationGroups.status, "RESOLVED"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const [fpResult] = await db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(correlationGroups)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(correlationGroups.userId, userId),
|
||||||
|
eq(correlationGroups.status, "FALSE_POSITIVE"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const threat = await getThreatScore(userId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalAlerts: totalResult.count,
|
||||||
|
bySeverity: Object.fromEntries(bySeverity.map((r) => [r.severity, r.count])),
|
||||||
|
bySource: Object.fromEntries(bySource.map((r) => [r.source, r.count])),
|
||||||
|
activeGroups: activeGroupsResult.count,
|
||||||
|
resolvedCount: resolvedResult.count,
|
||||||
|
falsePositiveCount: fpResult.count,
|
||||||
|
threatScore: threat.score,
|
||||||
|
threatBreakdown: threat.breakdown,
|
||||||
|
};
|
||||||
|
}
|
||||||
257
web/src/server/services/correlation/engine.test.ts
Normal file
257
web/src/server/services/correlation/engine.test.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
const mockSelect = vi.fn();
|
||||||
|
const mockInsert = vi.fn();
|
||||||
|
const mockUpdate = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("~/server/db", () => ({
|
||||||
|
db: {
|
||||||
|
select: mockSelect,
|
||||||
|
insert: mockInsert,
|
||||||
|
update: mockUpdate,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("~/server/db/schema", () => ({
|
||||||
|
normalizedAlerts: {},
|
||||||
|
correlationGroups: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
interface MockChain {
|
||||||
|
from: ReturnType<typeof vi.fn>;
|
||||||
|
where: ReturnType<typeof vi.fn>;
|
||||||
|
values: ReturnType<typeof vi.fn>;
|
||||||
|
returning: ReturnType<typeof vi.fn>;
|
||||||
|
set: ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeSelectChain(data: unknown): MockChain {
|
||||||
|
const where = vi.fn().mockResolvedValue(data);
|
||||||
|
const from = vi.fn().mockReturnValue({ where });
|
||||||
|
const chain = { from, where } as MockChain;
|
||||||
|
mockSelect.mockReturnValue({ from });
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeInsertChain(data: unknown): MockChain {
|
||||||
|
const returning = vi.fn().mockResolvedValue(data);
|
||||||
|
const values = vi.fn().mockReturnValue({ returning });
|
||||||
|
const chain = { values, returning } as MockChain;
|
||||||
|
mockInsert.mockReturnValue({ values });
|
||||||
|
return chain;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeUpdateChain(): { set: ReturnType<typeof vi.fn>; where: ReturnType<typeof vi.fn> } {
|
||||||
|
const where = vi.fn().mockResolvedValue([]);
|
||||||
|
const set = vi.fn().mockReturnValue({ where });
|
||||||
|
mockUpdate.mockReturnValue({ set });
|
||||||
|
return { set, where };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getHighestSeverity", () => {
|
||||||
|
it("returns LOW for empty input", async () => {
|
||||||
|
const { getHighestSeverity } = await import("./engine");
|
||||||
|
expect(getHighestSeverity([])).toBe("LOW");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns highest severity from list", async () => {
|
||||||
|
const { getHighestSeverity } = await import("./engine");
|
||||||
|
expect(getHighestSeverity(["LOW", "HIGH", "INFO"])).toBe("HIGH");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns CRITICAL as highest", async () => {
|
||||||
|
const { getHighestSeverity } = await import("./engine");
|
||||||
|
expect(getHighestSeverity(["INFO", "WARNING", "CRITICAL"])).toBe("CRITICAL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns the only severity", async () => {
|
||||||
|
const { getHighestSeverity } = await import("./engine");
|
||||||
|
expect(getHighestSeverity(["MEDIUM"])).toBe("MEDIUM");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deduplicateAlerts", () => {
|
||||||
|
it("returns all inputs when none exist", async () => {
|
||||||
|
makeSelectChain([]);
|
||||||
|
|
||||||
|
const { deduplicateAlerts } = await import("./engine");
|
||||||
|
const inputs = [
|
||||||
|
{ source: "DARKWATCH" as const, sourceAlertId: "dw:1", category: "BREACH_EXPOSURE" as const, severity: "HIGH" as const, title: "Test", description: "Test", entities: { emails: [], phones: [], ssns: [] } },
|
||||||
|
];
|
||||||
|
const result = await deduplicateAlerts(inputs);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters out existing sourceAlertIds", async () => {
|
||||||
|
makeSelectChain([{ sourceAlertId: "dw:1" }]);
|
||||||
|
|
||||||
|
const { deduplicateAlerts } = await import("./engine");
|
||||||
|
const inputs = [
|
||||||
|
{ source: "DARKWATCH" as const, sourceAlertId: "dw:1", category: "BREACH_EXPOSURE" as const, severity: "HIGH" as const, title: "Test", description: "Test", entities: { emails: [], phones: [], ssns: [] } },
|
||||||
|
{ source: "SPAMSHIELD" as const, sourceAlertId: "ss:2", category: "SPAM_CALL" as const, severity: "WARNING" as const, title: "Test2", description: "Test2", entities: { emails: [], phones: [], ssns: [] } },
|
||||||
|
];
|
||||||
|
const result = await deduplicateAlerts(inputs);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].sourceAlertId).toBe("ss:2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty for all duplicates", async () => {
|
||||||
|
makeSelectChain([{ sourceAlertId: "dw:1" }, { sourceAlertId: "ss:2" }]);
|
||||||
|
|
||||||
|
const { deduplicateAlerts } = await import("./engine");
|
||||||
|
const inputs = [
|
||||||
|
{ source: "DARKWATCH" as const, sourceAlertId: "dw:1", category: "BREACH_EXPOSURE" as const, severity: "HIGH" as const, title: "Test", description: "Test", entities: { emails: [], phones: [], ssns: [] } },
|
||||||
|
{ source: "SPAMSHIELD" as const, sourceAlertId: "ss:2", category: "SPAM_CALL" as const, severity: "WARNING" as const, title: "Test2", description: "Test2", entities: { emails: [], phones: [], ssns: [] } },
|
||||||
|
];
|
||||||
|
const result = await deduplicateAlerts(inputs);
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array for empty input", async () => {
|
||||||
|
const { deduplicateAlerts } = await import("./engine");
|
||||||
|
const result = await deduplicateAlerts([]);
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createCorrelationGroup", () => {
|
||||||
|
it("creates group with correct highest severity", async () => {
|
||||||
|
makeInsertChain([{
|
||||||
|
id: "group-1",
|
||||||
|
userId: "user-1",
|
||||||
|
entities: { emails: ["test@example.com"], phones: [], ssns: [] },
|
||||||
|
highestSeverity: "CRITICAL",
|
||||||
|
alertCount: 3,
|
||||||
|
summary: "Correlated group of 3 alert(s)",
|
||||||
|
}]);
|
||||||
|
makeUpdateChain();
|
||||||
|
|
||||||
|
const { createCorrelationGroup } = await import("./engine");
|
||||||
|
const alerts = [
|
||||||
|
{ id: "a1", severity: "HIGH" },
|
||||||
|
{ id: "a2", severity: "CRITICAL" },
|
||||||
|
{ id: "a3", severity: "INFO" },
|
||||||
|
] as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
const group = await createCorrelationGroup(
|
||||||
|
alerts as never,
|
||||||
|
"user-1",
|
||||||
|
{ emails: ["test@example.com"], phones: [], ssns: [] },
|
||||||
|
);
|
||||||
|
expect(group.id).toBe("group-1");
|
||||||
|
expect(group.highestSeverity).toBe("CRITICAL");
|
||||||
|
expect(group.alertCount).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates group with unique alert IDs", async () => {
|
||||||
|
makeInsertChain([{
|
||||||
|
id: "group-2",
|
||||||
|
userId: "user-1",
|
||||||
|
entities: { emails: [], phones: ["+1234567890"], ssns: [] },
|
||||||
|
highestSeverity: "WARNING",
|
||||||
|
alertCount: 1,
|
||||||
|
summary: "Correlated group of 1 alert(s)",
|
||||||
|
}]);
|
||||||
|
makeUpdateChain();
|
||||||
|
|
||||||
|
const { createCorrelationGroup } = await import("./engine");
|
||||||
|
const alerts = [
|
||||||
|
{ id: "a1", severity: "WARNING" },
|
||||||
|
{ id: "a1", severity: "WARNING" },
|
||||||
|
] as Array<Record<string, unknown>>;
|
||||||
|
|
||||||
|
const group = await createCorrelationGroup(
|
||||||
|
alerts as never,
|
||||||
|
"user-1",
|
||||||
|
{ emails: [], phones: ["+1234567890"], ssns: [] },
|
||||||
|
);
|
||||||
|
expect(group.alertCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("updateGroupSeverity", () => {
|
||||||
|
it("updates group severity to highest among alerts", async () => {
|
||||||
|
makeSelectChain([
|
||||||
|
{ id: "a1", severity: "LOW" },
|
||||||
|
{ id: "a2", severity: "HIGH" },
|
||||||
|
]);
|
||||||
|
makeUpdateChain();
|
||||||
|
|
||||||
|
const { updateGroupSeverity } = await import("./engine");
|
||||||
|
await updateGroupSeverity("group-1");
|
||||||
|
const setCall = mockUpdate.mock.results[0]?.value.set;
|
||||||
|
expect(setCall).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ highestSeverity: "HIGH", alertCount: 2 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing for empty group", async () => {
|
||||||
|
makeSelectChain([]);
|
||||||
|
|
||||||
|
const { updateGroupSeverity } = await import("./engine");
|
||||||
|
await updateGroupSeverity("group-empty");
|
||||||
|
expect(mockUpdate).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("findRelatedAlerts", () => {
|
||||||
|
it("finds alerts sharing an email address", async () => {
|
||||||
|
const existingAlerts = [
|
||||||
|
{ id: "a2", userId: "user-1", entities: { emails: ["shared@example.com"], phones: [], ssns: [] }, severity: "HIGH" },
|
||||||
|
{ id: "a3", userId: "user-1", entities: { emails: ["other@example.com"], phones: [], ssns: [] }, severity: "LOW" },
|
||||||
|
];
|
||||||
|
|
||||||
|
makeSelectChain(existingAlerts);
|
||||||
|
|
||||||
|
const { findRelatedAlerts } = await import("./engine");
|
||||||
|
const result = await findRelatedAlerts("a1", "user-1", { emails: ["shared@example.com"], phones: [], ssns: [] });
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].id).toBe("a2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds alerts sharing a phone number", async () => {
|
||||||
|
const existingAlerts = [
|
||||||
|
{ id: "a2", userId: "user-1", entities: { emails: [], phones: ["+1234567890"], ssns: [] }, severity: "HIGH" },
|
||||||
|
];
|
||||||
|
|
||||||
|
makeSelectChain(existingAlerts);
|
||||||
|
|
||||||
|
const { findRelatedAlerts } = await import("./engine");
|
||||||
|
const result = await findRelatedAlerts("a1", "user-1", { emails: [], phones: ["+1234567890"], ssns: [] });
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds alerts sharing an SSN", async () => {
|
||||||
|
const existingAlerts = [
|
||||||
|
{ id: "a2", userId: "user-1", entities: { emails: [], phones: [], ssns: ["123-45-6789"] }, severity: "CRITICAL" },
|
||||||
|
];
|
||||||
|
|
||||||
|
makeSelectChain(existingAlerts);
|
||||||
|
|
||||||
|
const { findRelatedAlerts } = await import("./engine");
|
||||||
|
const result = await findRelatedAlerts("a1", "user-1", { emails: [], phones: [], ssns: ["123-45-6789"] });
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty when no entities match", async () => {
|
||||||
|
const existingAlerts = [
|
||||||
|
{ id: "a2", userId: "user-1", entities: { emails: ["other@example.com"], phones: [], ssns: [] }, severity: "HIGH" },
|
||||||
|
];
|
||||||
|
|
||||||
|
makeSelectChain(existingAlerts);
|
||||||
|
|
||||||
|
const { findRelatedAlerts } = await import("./engine");
|
||||||
|
const result = await findRelatedAlerts("a1", "user-1", { emails: ["unrelated@example.com"], phones: [], ssns: [] });
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty when no entities provided", async () => {
|
||||||
|
const { findRelatedAlerts } = await import("./engine");
|
||||||
|
const result = await findRelatedAlerts("a1", "user-1", { emails: [], phones: [], ssns: [] });
|
||||||
|
expect(result).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
127
web/src/server/services/correlation/engine.ts
Normal file
127
web/src/server/services/correlation/engine.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { and, eq, gte, inArray, not, sql } from "drizzle-orm";
|
||||||
|
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||||
|
import { db } from "~/server/db";
|
||||||
|
import { normalizedAlerts, correlationGroups } from "~/server/db/schema";
|
||||||
|
import type { NormalizedAlertInput, EntitySet } from "./normalizer";
|
||||||
|
import type * as schema from "~/server/db/schema";
|
||||||
|
|
||||||
|
const SEVERITY_ORDER: Record<string, number> = {
|
||||||
|
LOW: 0,
|
||||||
|
INFO: 1,
|
||||||
|
MEDIUM: 2,
|
||||||
|
WARNING: 3,
|
||||||
|
HIGH: 4,
|
||||||
|
CRITICAL: 5,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getHighestSeverity(severities: string[]): string {
|
||||||
|
let highest = "LOW";
|
||||||
|
let highestOrder = 0;
|
||||||
|
for (const s of severities) {
|
||||||
|
const order = SEVERITY_ORDER[s] ?? 0;
|
||||||
|
if (order > highestOrder) {
|
||||||
|
highestOrder = order;
|
||||||
|
highest = s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return highest;
|
||||||
|
}
|
||||||
|
|
||||||
|
type NormalizedAlert = typeof normalizedAlerts.$inferSelect;
|
||||||
|
|
||||||
|
export async function findRelatedAlerts(
|
||||||
|
alertId: string,
|
||||||
|
userId: string,
|
||||||
|
entities: EntitySet,
|
||||||
|
): Promise<NormalizedAlert[]> {
|
||||||
|
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||||
|
const allEntityValues = [...entities.emails, ...entities.phones, ...entities.ssns];
|
||||||
|
if (allEntityValues.length === 0) return [];
|
||||||
|
|
||||||
|
const alerts = await db
|
||||||
|
.select()
|
||||||
|
.from(normalizedAlerts)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(normalizedAlerts.userId, userId),
|
||||||
|
not(eq(normalizedAlerts.id, alertId)),
|
||||||
|
gte(normalizedAlerts.createdAt, thirtyDaysAgo),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return alerts.filter((a) => entitiesOverlap(a.entities as EntitySet, entities));
|
||||||
|
}
|
||||||
|
|
||||||
|
function entitiesOverlap(a: EntitySet, b: EntitySet): boolean {
|
||||||
|
const aSet = new Set([...a.emails, ...a.phones, ...a.ssns]);
|
||||||
|
for (const val of [...b.emails, ...b.phones, ...b.ssns]) {
|
||||||
|
if (aSet.has(val)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createCorrelationGroup(
|
||||||
|
alerts: NormalizedAlert[],
|
||||||
|
userId: string,
|
||||||
|
entities: EntitySet,
|
||||||
|
): Promise<typeof correlationGroups.$inferSelect> {
|
||||||
|
const severities = alerts.map((a) => a.severity);
|
||||||
|
const highestSeverity = getHighestSeverity(severities);
|
||||||
|
|
||||||
|
const alertIds = [...new Set(alerts.map((a) => a.id))];
|
||||||
|
|
||||||
|
const [group] = await db
|
||||||
|
.insert(correlationGroups)
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
entities: entities as unknown as Record<string, unknown>,
|
||||||
|
highestSeverity: highestSeverity as "LOW" | "INFO" | "MEDIUM" | "WARNING" | "HIGH" | "CRITICAL",
|
||||||
|
alertCount: alertIds.length,
|
||||||
|
summary: `Correlated group of ${alertIds.length} alert(s)`,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
if (alertIds.length > 0) {
|
||||||
|
await db
|
||||||
|
.update(normalizedAlerts)
|
||||||
|
.set({ groupId: group.id })
|
||||||
|
.where(inArray(normalizedAlerts.id, alertIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateGroupSeverity(groupId: string): Promise<void> {
|
||||||
|
const groupAlerts = await db
|
||||||
|
.select()
|
||||||
|
.from(normalizedAlerts)
|
||||||
|
.where(eq(normalizedAlerts.groupId, groupId));
|
||||||
|
|
||||||
|
if (groupAlerts.length === 0) return;
|
||||||
|
|
||||||
|
const severities = groupAlerts.map((a) => a.severity);
|
||||||
|
const highestSeverity = getHighestSeverity(severities);
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(correlationGroups)
|
||||||
|
.set({
|
||||||
|
highestSeverity: highestSeverity as "LOW" | "INFO" | "MEDIUM" | "WARNING" | "HIGH" | "CRITICAL",
|
||||||
|
alertCount: groupAlerts.length,
|
||||||
|
})
|
||||||
|
.where(eq(correlationGroups.id, groupId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deduplicateAlerts(
|
||||||
|
inputs: NormalizedAlertInput[],
|
||||||
|
): Promise<NormalizedAlertInput[]> {
|
||||||
|
if (inputs.length === 0) return [];
|
||||||
|
|
||||||
|
const sourceAlertIds = inputs.map((i) => i.sourceAlertId);
|
||||||
|
const existing = await db
|
||||||
|
.select({ sourceAlertId: normalizedAlerts.sourceAlertId })
|
||||||
|
.from(normalizedAlerts)
|
||||||
|
.where(inArray(normalizedAlerts.sourceAlertId, sourceAlertIds));
|
||||||
|
|
||||||
|
const existingSet = new Set(existing.map((e) => e.sourceAlertId));
|
||||||
|
return inputs.filter((i) => !existingSet.has(i.sourceAlertId));
|
||||||
|
}
|
||||||
164
web/src/server/services/correlation/normalizer.test.ts
Normal file
164
web/src/server/services/correlation/normalizer.test.ts
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
describe("extractEntities", () => {
|
||||||
|
it("extracts emails from text", async () => {
|
||||||
|
const { extractEntities } = await import("./normalizer");
|
||||||
|
const result = extractEntities("Contact me at user@example.com or admin@test.com");
|
||||||
|
expect(result.emails).toContain("user@example.com");
|
||||||
|
expect(result.emails).toContain("admin@test.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts phone numbers from text", async () => {
|
||||||
|
const { extractEntities } = await import("./normalizer");
|
||||||
|
const result = extractEntities("Call +14155551234 or 2125551234");
|
||||||
|
expect(result.phones.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("extracts SSNs from text", async () => {
|
||||||
|
const { extractEntities } = await import("./normalizer");
|
||||||
|
const result = extractEntities("SSN: 123-45-6789");
|
||||||
|
expect(result.ssns).toContain("123-45-6789");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty arrays for no matches", async () => {
|
||||||
|
const { extractEntities } = await import("./normalizer");
|
||||||
|
const result = extractEntities("No entities here");
|
||||||
|
expect(result.emails).toEqual([]);
|
||||||
|
expect(result.phones).toEqual([]);
|
||||||
|
expect(result.ssns).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates entities", async () => {
|
||||||
|
const { extractEntities } = await import("./normalizer");
|
||||||
|
const result = extractEntities("a@b.com a@b.com");
|
||||||
|
expect(result.emails).toEqual(["a@b.com"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeDarkWatchAlert", () => {
|
||||||
|
it("creates NormalizedAlert with correct severity mapping", async () => {
|
||||||
|
const { normalizeDarkWatchAlert } = await import("./normalizer");
|
||||||
|
const result = normalizeDarkWatchAlert({
|
||||||
|
id: "exp-1",
|
||||||
|
identifier: "user@example.com",
|
||||||
|
severity: "critical",
|
||||||
|
source: "hibp",
|
||||||
|
});
|
||||||
|
expect(result.source).toBe("DARKWATCH");
|
||||||
|
expect(result.sourceAlertId).toBe("darkwatch:exp-1");
|
||||||
|
expect(result.category).toBe("BREACH_EXPOSURE");
|
||||||
|
expect(result.severity).toBe("CRITICAL");
|
||||||
|
expect(result.entities.emails).toContain("user@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps warning severity correctly", async () => {
|
||||||
|
const { normalizeDarkWatchAlert } = await import("./normalizer");
|
||||||
|
const result = normalizeDarkWatchAlert({
|
||||||
|
id: "exp-2",
|
||||||
|
identifier: "+1234567890",
|
||||||
|
severity: "warning",
|
||||||
|
source: "shodan",
|
||||||
|
});
|
||||||
|
expect(result.severity).toBe("WARNING");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps info severity correctly", async () => {
|
||||||
|
const { normalizeDarkWatchAlert } = await import("./normalizer");
|
||||||
|
const result = normalizeDarkWatchAlert({
|
||||||
|
id: "exp-3",
|
||||||
|
identifier: "domain.com",
|
||||||
|
severity: "info",
|
||||||
|
source: "censys",
|
||||||
|
});
|
||||||
|
expect(result.severity).toBe("INFO");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeSpamShieldAlert", () => {
|
||||||
|
it("creates SPAM_CALL alert for phone calls", async () => {
|
||||||
|
const { normalizeSpamShieldAlert } = await import("./normalizer");
|
||||||
|
const result = normalizeSpamShieldAlert({
|
||||||
|
id: "det-1",
|
||||||
|
callerNumber: "+1234567890",
|
||||||
|
verdict: "SYNTHETIC",
|
||||||
|
});
|
||||||
|
expect(result.source).toBe("SPAMSHIELD");
|
||||||
|
expect(result.category).toBe("SPAM_CALL");
|
||||||
|
expect(result.severity).toBe("HIGH");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates SPAM_SMS alert for SMS", async () => {
|
||||||
|
const { normalizeSpamShieldAlert } = await import("./normalizer");
|
||||||
|
const result = normalizeSpamShieldAlert({
|
||||||
|
id: "det-2",
|
||||||
|
callerNumber: "+1234567890",
|
||||||
|
verdict: "UNCERTAIN",
|
||||||
|
type: "sms",
|
||||||
|
});
|
||||||
|
expect(result.category).toBe("SPAM_SMS");
|
||||||
|
expect(result.severity).toBe("WARNING");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses INFO for NATURAL verdict", async () => {
|
||||||
|
const { normalizeSpamShieldAlert } = await import("./normalizer");
|
||||||
|
const result = normalizeSpamShieldAlert({
|
||||||
|
id: "det-3",
|
||||||
|
callerNumber: "+1234567890",
|
||||||
|
verdict: "NATURAL",
|
||||||
|
});
|
||||||
|
expect(result.severity).toBe("INFO");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeVoicePrintAlert", () => {
|
||||||
|
it("creates SYNTHETIC_VOICE alert", async () => {
|
||||||
|
const { normalizeVoicePrintAlert } = await import("./normalizer");
|
||||||
|
const result = normalizeVoicePrintAlert({
|
||||||
|
id: "an-1",
|
||||||
|
recordingId: "rec-1",
|
||||||
|
verdict: "SYNTHETIC",
|
||||||
|
});
|
||||||
|
expect(result.source).toBe("VOICEPRINT");
|
||||||
|
expect(result.category).toBe("SYNTHETIC_VOICE");
|
||||||
|
expect(result.severity).toBe("CRITICAL");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses WARNING for UNCERTAIN verdict", async () => {
|
||||||
|
const { normalizeVoicePrintAlert } = await import("./normalizer");
|
||||||
|
const result = normalizeVoicePrintAlert({
|
||||||
|
id: "an-2",
|
||||||
|
recordingId: "rec-2",
|
||||||
|
verdict: "UNCERTAIN",
|
||||||
|
});
|
||||||
|
expect(result.severity).toBe("WARNING");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeHomeTitleAlert", () => {
|
||||||
|
it("creates HOME_TITLE alert", async () => {
|
||||||
|
const { normalizeHomeTitleAlert } = await import("./normalizer");
|
||||||
|
const result = normalizeHomeTitleAlert({
|
||||||
|
id: "ch-1",
|
||||||
|
propertyAddress: "123 Main St, Springfield",
|
||||||
|
changeType: "ownership_transfer",
|
||||||
|
severity: "critical",
|
||||||
|
});
|
||||||
|
expect(result.source).toBe("HOME_TITLE");
|
||||||
|
expect(result.category).toBe("HOME_TITLE");
|
||||||
|
expect(result.severity).toBe("CRITICAL");
|
||||||
|
expect(result.entities.emails.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeRemoveBrokersAlert", () => {
|
||||||
|
it("creates INFO_BROKER alert", async () => {
|
||||||
|
const { normalizeRemoveBrokersAlert } = await import("./normalizer");
|
||||||
|
const result = normalizeRemoveBrokersAlert({
|
||||||
|
id: "br-1",
|
||||||
|
brokerName: "Spokeo",
|
||||||
|
});
|
||||||
|
expect(result.source).toBe("INFO_BROKER");
|
||||||
|
expect(result.category).toBe("INFO_BROKER_LISTING");
|
||||||
|
expect(result.severity).toBe("WARNING");
|
||||||
|
});
|
||||||
|
});
|
||||||
148
web/src/server/services/correlation/normalizer.ts
Normal file
148
web/src/server/services/correlation/normalizer.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import type {
|
||||||
|
alertSource,
|
||||||
|
alertCategory,
|
||||||
|
normalizedAlertSeverity,
|
||||||
|
} from "~/server/db/schema/enums";
|
||||||
|
|
||||||
|
export type AlertSource = (typeof alertSource.enumValues)[number];
|
||||||
|
export type AlertCategory = (typeof alertCategory.enumValues)[number];
|
||||||
|
export type NormalizedSeverity = (typeof normalizedAlertSeverity.enumValues)[number];
|
||||||
|
|
||||||
|
export interface EntitySet {
|
||||||
|
emails: string[];
|
||||||
|
phones: string[];
|
||||||
|
ssns: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NormalizedAlertInput {
|
||||||
|
source: AlertSource;
|
||||||
|
sourceAlertId: string;
|
||||||
|
category: AlertCategory;
|
||||||
|
severity: NormalizedSeverity;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
entities: EntitySet;
|
||||||
|
payload?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMAIL_RE = /[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/g;
|
||||||
|
const PHONE_RE = /\+?1?\d{10,15}/g;
|
||||||
|
const SSN_RE = /\d{3}-\d{2}-\d{4}/g;
|
||||||
|
|
||||||
|
export function extractEntities(text: string): EntitySet {
|
||||||
|
const emails = [...new Set(text.match(EMAIL_RE) ?? [])];
|
||||||
|
const phones = [...new Set(text.match(PHONE_RE) ?? [])];
|
||||||
|
const ssns = [...new Set(text.match(SSN_RE) ?? [])];
|
||||||
|
return { emails, phones, ssns };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapToNormalizedSeverity(severity: string): NormalizedSeverity {
|
||||||
|
switch (severity) {
|
||||||
|
case "critical":
|
||||||
|
return "CRITICAL";
|
||||||
|
case "warning":
|
||||||
|
return "WARNING";
|
||||||
|
case "info":
|
||||||
|
return "INFO";
|
||||||
|
default:
|
||||||
|
return "LOW";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeDarkWatchAlert(exposure: {
|
||||||
|
id: string;
|
||||||
|
identifier: string;
|
||||||
|
severity: string;
|
||||||
|
source: string;
|
||||||
|
dataType?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}): NormalizedAlertInput {
|
||||||
|
return {
|
||||||
|
source: "DARKWATCH",
|
||||||
|
sourceAlertId: `darkwatch:${exposure.id}`,
|
||||||
|
category: "BREACH_EXPOSURE",
|
||||||
|
severity: mapToNormalizedSeverity(exposure.severity),
|
||||||
|
title: `Data breach exposure for ${exposure.identifier}`,
|
||||||
|
description: `Exposure detected from ${exposure.source} involving ${exposure.dataType ?? "personal data"}`,
|
||||||
|
entities: extractEntities(exposure.identifier),
|
||||||
|
payload: exposure.metadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSpamShieldAlert(detection: {
|
||||||
|
id: string;
|
||||||
|
callerNumber: string;
|
||||||
|
verdict: string;
|
||||||
|
type?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}): NormalizedAlertInput {
|
||||||
|
const category = detection.type === "sms" ? "SPAM_SMS" : "SPAM_CALL";
|
||||||
|
const severity = detection.verdict === "SYNTHETIC" ? "HIGH" : detection.verdict === "UNCERTAIN" ? "WARNING" : "INFO";
|
||||||
|
return {
|
||||||
|
source: "SPAMSHIELD",
|
||||||
|
sourceAlertId: `spamshield:${detection.id}`,
|
||||||
|
category,
|
||||||
|
severity,
|
||||||
|
title: `Spam ${detection.type ?? "call"} detected from ${detection.callerNumber}`,
|
||||||
|
description: `${detection.type ?? "Call"} flagged as ${detection.verdict} spam`,
|
||||||
|
entities: extractEntities(detection.callerNumber),
|
||||||
|
payload: detection.metadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeVoicePrintAlert(analysis: {
|
||||||
|
id: string;
|
||||||
|
recordingId: string;
|
||||||
|
verdict: string;
|
||||||
|
callerNumber?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}): NormalizedAlertInput {
|
||||||
|
const severity = analysis.verdict === "SYNTHETIC" ? "CRITICAL" : analysis.verdict === "UNCERTAIN" ? "WARNING" : "INFO";
|
||||||
|
return {
|
||||||
|
source: "VOICEPRINT",
|
||||||
|
sourceAlertId: `voiceprint:${analysis.id}`,
|
||||||
|
category: "SYNTHETIC_VOICE",
|
||||||
|
severity,
|
||||||
|
title: `Voice analysis alert for recording ${analysis.recordingId}`,
|
||||||
|
description: `Voice analysis verdict: ${analysis.verdict}`,
|
||||||
|
entities: extractEntities(analysis.callerNumber ?? analysis.recordingId),
|
||||||
|
payload: analysis.metadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeHomeTitleAlert(change: {
|
||||||
|
id: string;
|
||||||
|
propertyAddress: string;
|
||||||
|
changeType: string;
|
||||||
|
severity: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}): NormalizedAlertInput {
|
||||||
|
return {
|
||||||
|
source: "HOME_TITLE",
|
||||||
|
sourceAlertId: `hometitle:${change.id}`,
|
||||||
|
category: "HOME_TITLE",
|
||||||
|
severity: mapToNormalizedSeverity(change.severity),
|
||||||
|
title: `Property change detected: ${change.changeType}`,
|
||||||
|
description: `${change.changeType} detected for ${change.propertyAddress}`,
|
||||||
|
entities: extractEntities(change.propertyAddress),
|
||||||
|
payload: change.metadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeRemoveBrokersAlert(listing: {
|
||||||
|
id: string;
|
||||||
|
brokerName: string;
|
||||||
|
listingUrl?: string;
|
||||||
|
metadata?: Record<string, unknown>;
|
||||||
|
}): NormalizedAlertInput {
|
||||||
|
return {
|
||||||
|
source: "INFO_BROKER",
|
||||||
|
sourceAlertId: `infobroker:${listing.id}`,
|
||||||
|
category: "INFO_BROKER_LISTING",
|
||||||
|
severity: "WARNING",
|
||||||
|
title: `Broker listing found on ${listing.brokerName}`,
|
||||||
|
description: `Personal information listed on ${listing.brokerName}${listing.listingUrl ? ` (${listing.listingUrl})` : ""}`,
|
||||||
|
entities: { emails: [], phones: [], ssns: [] },
|
||||||
|
payload: listing.metadata,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user