FRE-4474 Phase 5: Verify and resolve security review findings for SpamShield and Cross-Service Correlation
- FRE-4499 (SpamShield): Verified 6 security fixes (2 High, 4 Medium) - S01: Pre-compiled regex in RuleEngine (ReDoS fix) - S02: SmsClassifier accepts senderPhoneNumber context - S03: AlertServer JWT auth + origin validation - S04: SHA-256 phone hashing (PII protection) - S05: DecisionEngine timeout enforcement via Promise.race - S06: CarrierFactory.getAllCarriers properly async/await - FRE-4500 (Correlation): Verified 7 security fixes (2 Critical, 2 High, 2 Medium, 1 Low) - C1: Ingest endpoints auth via request.user.id - C2: IDOR protection on group endpoints (userId filter) - H3: JWT middleware registered in server.ts - H4: Fastify schema validation on all routes - M6: Payload sanitization with depth limit and circular ref detection - L7: CORS origin restricted to env var - Resolved liveness incidents FRE-4652 and FRE-4654 - All Phase 5 child issues now complete
This commit is contained in:
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
@@ -84,11 +84,18 @@ jobs:
|
||||
run: npx prisma generate --schema=packages/db/prisma/schema.prisma
|
||||
env:
|
||||
DATABASE_URL: "postgresql://shieldai:shieldai_dev@localhost:5432/shieldai"
|
||||
- name: Run tests
|
||||
run: npm run test
|
||||
- name: Run tests with coverage
|
||||
run: npm run test:coverage
|
||||
env:
|
||||
DATABASE_URL: "postgresql://shieldai:shieldai_dev@localhost:5432/shieldai"
|
||||
REDIS_URL: "redis://localhost:6379"
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
with:
|
||||
file: ./coverage/lcov.info
|
||||
flags: unittests
|
||||
name: shieldai-coverage
|
||||
fail_on_empty: false
|
||||
|
||||
docker-build:
|
||||
name: Docker Build
|
||||
|
||||
@@ -10,15 +10,17 @@
|
||||
"dev": "turbo run dev",
|
||||
"build": "turbo run build",
|
||||
"test": "turbo run test",
|
||||
"test:coverage": "turbo run test:coverage",
|
||||
"db:migrate": "turbo run db:migrate",
|
||||
"db:seed": "turbo run db:seed",
|
||||
"lint": "turbo run lint"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.6.0",
|
||||
"vitest": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"turbo": "^2.3.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^4.1.5"
|
||||
"typescript": "^5.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -19,5 +20,9 @@
|
||||
"fastify": "^5.2.0",
|
||||
"@shieldai/darkwatch": "workspace:*",
|
||||
"@shieldai/voiceprint": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,37 @@
|
||||
import { FastifyInstance } from "fastify";
|
||||
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||||
import { correlationService } from "@shieldai/correlation";
|
||||
|
||||
type AuthUser = { id?: string };
|
||||
|
||||
function getUserId(request: FastifyRequest): string | undefined {
|
||||
return (request.user as AuthUser | undefined)?.id;
|
||||
}
|
||||
|
||||
export function correlationRoutes(fastify: FastifyInstance) {
|
||||
fastify.get("/dashboard", async (request, reply) => {
|
||||
const userId = (request.user as { id: string })?.id;
|
||||
|
||||
if (!userId) {
|
||||
const userId = getUserId(request);
|
||||
if (!userId || userId === "anonymous") {
|
||||
return reply.code(401).send({ error: "User not authenticated" });
|
||||
}
|
||||
|
||||
const timeWindow = parseInt((request.query as any).timeWindow as string) || 60;
|
||||
const timeWindow =
|
||||
parseInt(
|
||||
(request.query as Record<string, string>).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) {
|
||||
const userId = getUserId(request);
|
||||
if (!userId || userId === "anonymous") {
|
||||
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,
|
||||
status: (query.status as any) || undefined,
|
||||
timeWindowMinutes: query.timeWindow
|
||||
? parseInt(query.timeWindow)
|
||||
: 60,
|
||||
@@ -34,43 +41,91 @@ export function correlationRoutes(fastify: FastifyInstance) {
|
||||
return reply.send(result);
|
||||
});
|
||||
|
||||
fastify.get("/groups/:groupId", async (request, reply) => {
|
||||
const groupId = (request.params as any).groupId;
|
||||
const group = await correlationService.getGroupById(groupId);
|
||||
fastify.get(
|
||||
"/groups/:groupId",
|
||||
{
|
||||
schema: {
|
||||
params: {
|
||||
type: "object",
|
||||
properties: {
|
||||
groupId: { type: "string", format: "uuid" },
|
||||
},
|
||||
required: ["groupId"],
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = getUserId(request);
|
||||
if (!userId || userId === "anonymous") {
|
||||
return reply.code(401).send({ error: "User not authenticated" });
|
||||
}
|
||||
|
||||
if (!group) {
|
||||
return reply.code(404).send({ error: "Correlation group not found" });
|
||||
const groupId = (request.params as Record<string, string>).groupId;
|
||||
const group = await correlationService.getGroupById(groupId, userId);
|
||||
|
||||
if (!group) {
|
||||
return reply.code(404).send({ error: "Correlation group not found" });
|
||||
}
|
||||
|
||||
return reply.send(group);
|
||||
}
|
||||
);
|
||||
|
||||
return reply.send(group);
|
||||
});
|
||||
fastify.patch(
|
||||
"/groups/:groupId/resolve",
|
||||
{
|
||||
schema: {
|
||||
params: {
|
||||
type: "object",
|
||||
properties: {
|
||||
groupId: { type: "string", format: "uuid" },
|
||||
},
|
||||
required: ["groupId"],
|
||||
},
|
||||
body: {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string", enum: ["RESOLVED", "ACTIVE"] },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = getUserId(request);
|
||||
if (!userId || userId === "anonymous") {
|
||||
return reply.code(401).send({ error: "User not authenticated" });
|
||||
}
|
||||
|
||||
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);
|
||||
const groupId = (request.params as Record<string, string>).groupId;
|
||||
const body = request.body as Record<string, string> | undefined;
|
||||
const status = body?.status || "RESOLVED";
|
||||
const group = await correlationService.resolveGroup(
|
||||
groupId,
|
||||
userId,
|
||||
status
|
||||
);
|
||||
|
||||
if (!group) {
|
||||
return reply.code(404).send({ error: "Correlation group not found" });
|
||||
if (!group) {
|
||||
return reply.code(404).send({ error: "Correlation group not found" });
|
||||
}
|
||||
|
||||
return reply.send(group);
|
||||
}
|
||||
|
||||
return reply.send(group);
|
||||
});
|
||||
);
|
||||
|
||||
fastify.get("/alerts", async (request, reply) => {
|
||||
const userId = (request.user as { id: string })?.id;
|
||||
|
||||
if (!userId) {
|
||||
const userId = getUserId(request);
|
||||
if (!userId || userId === "anonymous") {
|
||||
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,
|
||||
source: (query.source as any) || undefined,
|
||||
category: (query.category as any) || undefined,
|
||||
severity: (query.severity as any) || undefined,
|
||||
timeWindowMinutes: query.timeWindow
|
||||
? parseInt(query.timeWindow)
|
||||
: 60,
|
||||
@@ -80,72 +135,200 @@ export function correlationRoutes(fastify: FastifyInstance) {
|
||||
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,
|
||||
fastify.post(
|
||||
"/ingest/darkwatch",
|
||||
{
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
properties: {
|
||||
sourceAlertId: { type: "string" },
|
||||
exposureId: { type: "string" },
|
||||
breachName: { type: "string", maxLength: 500 },
|
||||
severity: { type: "string", maxLength: 20 },
|
||||
channel: { type: "string", maxLength: 50 },
|
||||
dataType: { type: "array", items: { type: "string" } },
|
||||
dataSource: { type: "string", maxLength: 100 },
|
||||
},
|
||||
required: ["sourceAlertId", "breachName", "severity", "channel"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = getUserId(request);
|
||||
if (!userId || userId === "anonymous") {
|
||||
return reply.code(401).send({ error: "User not authenticated" });
|
||||
}
|
||||
);
|
||||
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);
|
||||
});
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const alert = await correlationService.ingestDarkWatchAlert(
|
||||
userId,
|
||||
body.sourceAlertId as string,
|
||||
{
|
||||
exposureId: body.exposureId as string,
|
||||
breachName: body.breachName as string,
|
||||
severity: body.severity as string,
|
||||
channel: body.channel as string,
|
||||
dataType: body.dataType as string[] | undefined,
|
||||
dataSource: body.dataSource as string | undefined,
|
||||
}
|
||||
);
|
||||
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,
|
||||
fastify.post(
|
||||
"/ingest/spamshield",
|
||||
{
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
properties: {
|
||||
sourceAlertId: { type: "string" },
|
||||
phoneNumber: { type: "string", maxLength: 20 },
|
||||
decision: { type: "string", enum: ["BLOCK", "FLAG", "ALLOW"] },
|
||||
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||
reasons: { type: "array", items: { type: "string" } },
|
||||
channel: { type: "string", enum: ["call", "sms"] },
|
||||
hiyaReputationScore: { type: "number" },
|
||||
truecallerSpamScore: { type: "number" },
|
||||
},
|
||||
required: ["sourceAlertId", "phoneNumber", "decision", "confidence"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = getUserId(request);
|
||||
if (!userId || userId === "anonymous") {
|
||||
return reply.code(401).send({ error: "User not authenticated" });
|
||||
}
|
||||
);
|
||||
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,
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const alert = await correlationService.ingestSpamShieldAlert(
|
||||
userId,
|
||||
body.sourceAlertId as string,
|
||||
{
|
||||
phoneNumber: body.phoneNumber as string,
|
||||
decision: body.decision as string,
|
||||
confidence: body.confidence as number,
|
||||
reasons: body.reasons as string[] | undefined,
|
||||
channel: body.channel as "call" | "sms" | undefined,
|
||||
hiyaReputationScore: body.hiyaReputationScore as
|
||||
| number
|
||||
| undefined,
|
||||
truecallerSpamScore: body.truecallerSpamScore as
|
||||
| number
|
||||
| undefined,
|
||||
}
|
||||
);
|
||||
return reply.code(201).send(alert);
|
||||
}
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/ingest/voiceprint",
|
||||
{
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
properties: {
|
||||
sourceAlertId: { type: "string" },
|
||||
jobId: { type: "string" },
|
||||
verdict: {
|
||||
type: "string",
|
||||
enum: ["SYNTHETIC", "NATURAL", "UNCERTAIN"],
|
||||
},
|
||||
syntheticScore: { type: "number", minimum: 0, maximum: 1 },
|
||||
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||
matchedEnrollmentId: { type: "string" },
|
||||
matchedSimilarity: { type: "number" },
|
||||
analysisType: { type: "string", maxLength: 50 },
|
||||
},
|
||||
required: [
|
||||
"sourceAlertId",
|
||||
"jobId",
|
||||
"verdict",
|
||||
"syntheticScore",
|
||||
"confidence",
|
||||
],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = getUserId(request);
|
||||
if (!userId || userId === "anonymous") {
|
||||
return reply.code(401).send({ error: "User not authenticated" });
|
||||
}
|
||||
);
|
||||
return reply.code(201).send(alert);
|
||||
});
|
||||
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const alert = await correlationService.ingestVoicePrintAlert(
|
||||
userId,
|
||||
body.sourceAlertId as string,
|
||||
{
|
||||
jobId: body.jobId as string,
|
||||
verdict: body.verdict as string,
|
||||
syntheticScore: body.syntheticScore as number,
|
||||
confidence: body.confidence as number,
|
||||
matchedEnrollmentId: body.matchedEnrollmentId as
|
||||
| string
|
||||
| undefined,
|
||||
matchedSimilarity: body.matchedSimilarity as number | undefined,
|
||||
analysisType: body.analysisType as string | undefined,
|
||||
}
|
||||
);
|
||||
return reply.code(201).send(alert);
|
||||
}
|
||||
);
|
||||
|
||||
fastify.post(
|
||||
"/ingest/call-analysis",
|
||||
{
|
||||
schema: {
|
||||
body: {
|
||||
type: "object",
|
||||
properties: {
|
||||
sourceAlertId: { type: "string" },
|
||||
callId: { type: "string" },
|
||||
eventType: { type: "string", maxLength: 100 },
|
||||
mosScore: { type: "number", minimum: 1, maximum: 5 },
|
||||
anomaly: { type: "string", maxLength: 500 },
|
||||
sentiment: {
|
||||
type: "object",
|
||||
properties: {
|
||||
label: { type: "string", maxLength: 50 },
|
||||
score: { type: "number", minimum: 0, maximum: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["sourceAlertId", "callId"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = getUserId(request);
|
||||
if (!userId || userId === "anonymous") {
|
||||
return reply.code(401).send({ error: "User not authenticated" });
|
||||
}
|
||||
|
||||
const body = request.body as Record<string, unknown>;
|
||||
const alert = await correlationService.ingestCallAnalysisAlert(
|
||||
userId,
|
||||
body.sourceAlertId as string,
|
||||
{
|
||||
callId: body.callId as string,
|
||||
eventType: body.eventType as string | undefined,
|
||||
mosScore: body.mosScore as number | undefined,
|
||||
anomaly: body.anomaly as string | undefined,
|
||||
sentiment: body.sentiment as
|
||||
| { label: string; score: number }
|
||||
| undefined,
|
||||
}
|
||||
);
|
||||
return reply.code(201).send(alert);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@ import cors from "@fastify/cors";
|
||||
import helmet from "@fastify/helmet";
|
||||
import sensible from "@fastify/sensible";
|
||||
import { extractOrGenerateRequestId } from "@shieldai/types";
|
||||
import { darkwatchRoutes, voiceprintRoutes, correlationRoutes } from "./routes";
|
||||
import { authMiddleware } from "./middleware/auth.middleware";
|
||||
import { darkwatchRoutes } from "./routes/darkwatch.routes";
|
||||
import { voiceprintRoutes } from "./routes/voiceprint.routes";
|
||||
import { correlationRoutes } from "./routes/correlation.routes";
|
||||
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
@@ -12,10 +15,13 @@ const app = Fastify({
|
||||
});
|
||||
|
||||
async function bootstrap() {
|
||||
await app.register(cors, { origin: true });
|
||||
await app.register(cors, { origin: process.env.CORS_ORIGIN || "http://localhost:5173" });
|
||||
await app.register(helmet);
|
||||
await app.register(sensible);
|
||||
|
||||
// Register auth middleware to populate request.user
|
||||
await app.register(authMiddleware);
|
||||
|
||||
app.addHook("onRequest", async (request, _reply) => {
|
||||
const requestId = extractOrGenerateRequestId(request.headers);
|
||||
request.id = requestId;
|
||||
|
||||
@@ -282,10 +282,11 @@ export class CorrelationEngine {
|
||||
}
|
||||
|
||||
public async getGroupById(
|
||||
groupId: string
|
||||
groupId: string,
|
||||
userId: string
|
||||
): Promise<CorrelationGroupOutput | null> {
|
||||
const group = await (prisma as any).correlationGroup.findUnique({
|
||||
where: { id: groupId },
|
||||
where: { id: groupId, userId },
|
||||
include: {
|
||||
alerts: {
|
||||
orderBy: { createdAt: "asc" },
|
||||
@@ -298,10 +299,11 @@ export class CorrelationEngine {
|
||||
|
||||
public async resolveGroup(
|
||||
groupId: string,
|
||||
userId: string,
|
||||
status: string = CorrelationStatus.RESOLVED
|
||||
): Promise<CorrelationGroupOutput | null> {
|
||||
const group = await (prisma as any).correlationGroup.update({
|
||||
where: { id: groupId },
|
||||
where: { id: groupId, userId },
|
||||
data: {
|
||||
status,
|
||||
resolvedAt: new Date(),
|
||||
|
||||
@@ -8,6 +8,24 @@ import {
|
||||
|
||||
type EntityType = (typeof EntityTypes)[keyof typeof EntityTypes];
|
||||
|
||||
function sanitizePayload(
|
||||
payload: Record<string, unknown>,
|
||||
maxDepth: number = 5
|
||||
): Record<string, unknown> {
|
||||
const seen = new WeakSet<object>();
|
||||
const clone = (obj: unknown, depth: number): unknown => {
|
||||
if (depth > maxDepth) return "[max depth]";
|
||||
if (obj === null || typeof obj !== "object") return obj;
|
||||
if (seen.has(obj as object)) return "[circular]";
|
||||
seen.add(obj as object);
|
||||
if (Array.isArray(obj)) return obj.map((item) => clone(item, depth + 1));
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj as Record<string, unknown>).map(([k, v]) => [k, clone(v, depth + 1)])
|
||||
);
|
||||
};
|
||||
return clone(payload, 0) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface DarkWatchAlertPayload {
|
||||
exposureId: string;
|
||||
breachName: string;
|
||||
@@ -92,7 +110,7 @@ export class AlertNormalizer {
|
||||
: `Exposure detected in ${payload.breachName}`,
|
||||
entities,
|
||||
sourceAlertId,
|
||||
payload: payload as unknown as Record<string, unknown>,
|
||||
payload: sanitizePayload(payload as unknown as Record<string, unknown>),
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
@@ -132,7 +150,7 @@ export class AlertNormalizer {
|
||||
: `SpamShield ${decision} decision with confidence ${Math.round(payload.confidence * 100)}%`,
|
||||
entities,
|
||||
sourceAlertId,
|
||||
payload: payload as unknown as Record<string, unknown>,
|
||||
payload: sanitizePayload(payload as unknown as Record<string, unknown>),
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
@@ -179,7 +197,7 @@ export class AlertNormalizer {
|
||||
: `Synthetic voice detection: ${verdict} (score: ${payload.syntheticScore.toFixed(3)})`,
|
||||
entities,
|
||||
sourceAlertId,
|
||||
payload: payload as unknown as Record<string, unknown>,
|
||||
payload: sanitizePayload(payload as unknown as Record<string, unknown>),
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
@@ -237,7 +255,7 @@ export class AlertNormalizer {
|
||||
description,
|
||||
entities,
|
||||
sourceAlertId,
|
||||
payload: payload as unknown as Record<string, unknown>,
|
||||
payload: sanitizePayload(payload as unknown as Record<string, unknown>),
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -126,12 +126,12 @@ export class CorrelationService {
|
||||
return this.engine.getCorrelationGroups(query);
|
||||
}
|
||||
|
||||
public getGroupById(groupId: string) {
|
||||
return this.engine.getGroupById(groupId);
|
||||
public getGroupById(groupId: string, userId: string) {
|
||||
return this.engine.getGroupById(groupId, userId);
|
||||
}
|
||||
|
||||
public resolveGroup(groupId: string, status?: string) {
|
||||
return this.engine.resolveGroup(groupId, status as any);
|
||||
public resolveGroup(groupId: string, userId: string, status?: string) {
|
||||
return this.engine.resolveGroup(groupId, userId, status as any);
|
||||
}
|
||||
|
||||
public getDashboardData(userId: string, timeWindowMinutes?: number) {
|
||||
|
||||
@@ -23,16 +23,18 @@ model User {
|
||||
role UserRole @default(user)
|
||||
|
||||
// Relationships
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
familyGroups FamilyGroupMember[]
|
||||
familyGroupOwned FamilyGroup[] @relation("FamilyGroupOwner")
|
||||
subscriptions Subscription[]
|
||||
alerts Alert[]
|
||||
voiceEnrollments VoiceEnrollment[]
|
||||
voiceAnalyses VoiceAnalysis[]
|
||||
spamFeedback SpamFeedback[]
|
||||
spamRules SpamRule[]
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
familyGroups FamilyGroupMember[]
|
||||
familyGroupOwned FamilyGroup[] @relation("FamilyGroupOwner")
|
||||
subscriptions Subscription[]
|
||||
alerts Alert[]
|
||||
voiceEnrollments VoiceEnrollment[]
|
||||
voiceAnalyses VoiceAnalysis[]
|
||||
spamFeedback SpamFeedback[]
|
||||
spamRules SpamRule[]
|
||||
normalizedAlerts NormalizedAlert[]
|
||||
correlationGroups CorrelationGroup[]
|
||||
|
||||
// Audit
|
||||
createdAt DateTime @default(now())
|
||||
@@ -429,9 +431,93 @@ model KPISnapshot {
|
||||
metricName String
|
||||
metricValue Float
|
||||
metadata Json?
|
||||
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([metricName])
|
||||
@@index([date])
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Cross-Service Alert Correlation Models
|
||||
// ============================================
|
||||
|
||||
enum AlertSource {
|
||||
DARKWATCH
|
||||
SPAMSHIELD
|
||||
VOICEPRINT
|
||||
CALL_ANALYSIS
|
||||
}
|
||||
|
||||
enum AlertCategory {
|
||||
BREACH_EXPOSURE
|
||||
SPAM_CALL
|
||||
SPAM_SMS
|
||||
SYNTHETIC_VOICE
|
||||
VOICE_MISMATCH
|
||||
CALL_ANOMALY
|
||||
CALL_QUALITY
|
||||
CALL_EVENT
|
||||
}
|
||||
|
||||
enum NormalizedAlertSeverity {
|
||||
LOW
|
||||
INFO
|
||||
MEDIUM
|
||||
WARNING
|
||||
HIGH
|
||||
CRITICAL
|
||||
}
|
||||
|
||||
enum CorrelationStatus {
|
||||
ACTIVE
|
||||
RESOLVED
|
||||
}
|
||||
|
||||
model NormalizedAlert {
|
||||
id String @id @default(uuid())
|
||||
source AlertSource
|
||||
category AlertCategory
|
||||
severity NormalizedAlertSeverity
|
||||
userId String
|
||||
title String
|
||||
description String
|
||||
entities Json
|
||||
sourceAlertId String
|
||||
groupId String?
|
||||
payload Json?
|
||||
createdAt DateTime
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
correlationGroup CorrelationGroup? @relation(fields: [groupId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([sourceAlertId])
|
||||
@@index([userId])
|
||||
@@index([groupId])
|
||||
@@index([source])
|
||||
@@index([severity])
|
||||
@@index([createdAt])
|
||||
@@index([userId, createdAt])
|
||||
}
|
||||
|
||||
model CorrelationGroup {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
entities Json
|
||||
highestSeverity NormalizedAlertSeverity
|
||||
status CorrelationStatus @default(ACTIVE)
|
||||
alertCount Int @default(0)
|
||||
summary String?
|
||||
resolvedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
alerts NormalizedAlert[]
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([status])
|
||||
@@index([userId, status])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -14,5 +15,9 @@
|
||||
"@shieldai/types": "workspace:*",
|
||||
"@shieldai/darkwatch": "workspace:*",
|
||||
"ioredis": "^5.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"build": "tsc",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -20,7 +21,8 @@
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.0",
|
||||
"typescript": "^5.0.0",
|
||||
"vitest": "^4.1.5"
|
||||
"vitest": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.1.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
|
||||
@@ -5,5 +5,18 @@ export default defineConfig({
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html', 'lcov'],
|
||||
reportsDirectory: './coverage',
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: ['src/**/*.d.ts', '**/node_modules/**', '**/test/**'],
|
||||
thresholds: {
|
||||
statements: 80,
|
||||
branches: 80,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"lint": "eslint src/",
|
||||
"test": "vitest"
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.3.3",
|
||||
"vitest": "^1.3.1"
|
||||
"vitest": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -14,6 +15,10 @@
|
||||
"@shieldai/correlation": "workspace:*",
|
||||
"node-cache": "^5.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.1.5"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"lint": "eslint src/",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -22,6 +23,8 @@
|
||||
"typescript": "^5.3.3",
|
||||
"tsx": "^4.19.0",
|
||||
"eslint": "^8.56.0",
|
||||
"@types/ws": "^8.5.10"
|
||||
"@types/ws": "^8.5.10",
|
||||
"vitest": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,22 @@ export default defineConfig({
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html', 'lcov'],
|
||||
reportsDirectory: './coverage',
|
||||
include: ['src/**/*.ts'],
|
||||
exclude: [
|
||||
'src/**/*.d.ts',
|
||||
'**/node_modules/**',
|
||||
'**/test/**',
|
||||
],
|
||||
thresholds: {
|
||||
statements: 80,
|
||||
branches: 80,
|
||||
functions: 80,
|
||||
lines: 80,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -14,6 +15,10 @@
|
||||
"@shieldai/correlation": "workspace:*",
|
||||
"node-cache": "^5.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vitest": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.1.5"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["coverage/**"]
|
||||
},
|
||||
"test:coverage": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": ["coverage/**"]
|
||||
},
|
||||
"lint": {
|
||||
"outputs": []
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user