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
|
run: npx prisma generate --schema=packages/db/prisma/schema.prisma
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: "postgresql://shieldai:shieldai_dev@localhost:5432/shieldai"
|
DATABASE_URL: "postgresql://shieldai:shieldai_dev@localhost:5432/shieldai"
|
||||||
- name: Run tests
|
- name: Run tests with coverage
|
||||||
run: npm run test
|
run: npm run test:coverage
|
||||||
env:
|
env:
|
||||||
DATABASE_URL: "postgresql://shieldai:shieldai_dev@localhost:5432/shieldai"
|
DATABASE_URL: "postgresql://shieldai:shieldai_dev@localhost:5432/shieldai"
|
||||||
REDIS_URL: "redis://localhost:6379"
|
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:
|
docker-build:
|
||||||
name: Docker Build
|
name: Docker Build
|
||||||
|
|||||||
@@ -10,15 +10,17 @@
|
|||||||
"dev": "turbo run dev",
|
"dev": "turbo run dev",
|
||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"test": "turbo run test",
|
"test": "turbo run test",
|
||||||
|
"test:coverage": "turbo run test:coverage",
|
||||||
"db:migrate": "turbo run db:migrate",
|
"db:migrate": "turbo run db:migrate",
|
||||||
"db:seed": "turbo run db:seed",
|
"db:seed": "turbo run db:seed",
|
||||||
"lint": "turbo run lint"
|
"lint": "turbo run lint"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^25.6.0",
|
"@types/node": "^25.6.0",
|
||||||
|
"vitest": "^4.1.5",
|
||||||
|
"@vitest/coverage-v8": "^4.1.5",
|
||||||
"turbo": "^2.3.0",
|
"turbo": "^2.3.0",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0"
|
||||||
"vitest": "^4.1.5"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/server.js",
|
"start": "node dist/server.js",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"lint": "eslint src/"
|
"lint": "eslint src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -19,5 +20,9 @@
|
|||||||
"fastify": "^5.2.0",
|
"fastify": "^5.2.0",
|
||||||
"@shieldai/darkwatch": "workspace:*",
|
"@shieldai/darkwatch": "workspace:*",
|
||||||
"@shieldai/voiceprint": "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";
|
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) {
|
export function correlationRoutes(fastify: FastifyInstance) {
|
||||||
fastify.get("/dashboard", async (request, reply) => {
|
fastify.get("/dashboard", async (request, reply) => {
|
||||||
const userId = (request.user as { id: string })?.id;
|
const userId = getUserId(request);
|
||||||
|
if (!userId || userId === "anonymous") {
|
||||||
if (!userId) {
|
|
||||||
return reply.code(401).send({ error: "User not authenticated" });
|
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);
|
const data = await correlationService.getDashboardData(userId, timeWindow);
|
||||||
return reply.send(data);
|
return reply.send(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.get("/groups", async (request, reply) => {
|
fastify.get("/groups", async (request, reply) => {
|
||||||
const userId = (request.user as { id: string })?.id;
|
const userId = getUserId(request);
|
||||||
|
if (!userId || userId === "anonymous") {
|
||||||
if (!userId) {
|
|
||||||
return reply.code(401).send({ error: "User not authenticated" });
|
return reply.code(401).send({ error: "User not authenticated" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = request.query as Record<string, string>;
|
const query = request.query as Record<string, string>;
|
||||||
const result = await correlationService.getCorrelationGroups({
|
const result = await correlationService.getCorrelationGroups({
|
||||||
userId,
|
userId,
|
||||||
status: query.status || undefined,
|
status: (query.status as any) || undefined,
|
||||||
timeWindowMinutes: query.timeWindow
|
timeWindowMinutes: query.timeWindow
|
||||||
? parseInt(query.timeWindow)
|
? parseInt(query.timeWindow)
|
||||||
: 60,
|
: 60,
|
||||||
@@ -34,43 +41,91 @@ export function correlationRoutes(fastify: FastifyInstance) {
|
|||||||
return reply.send(result);
|
return reply.send(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.get("/groups/:groupId", async (request, reply) => {
|
fastify.get(
|
||||||
const groupId = (request.params as any).groupId;
|
"/groups/:groupId",
|
||||||
const group = await correlationService.getGroupById(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) {
|
const groupId = (request.params as Record<string, string>).groupId;
|
||||||
return reply.code(404).send({ error: "Correlation group not found" });
|
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 Record<string, string>).groupId;
|
||||||
const groupId = (request.params as any).groupId;
|
const body = request.body as Record<string, string> | undefined;
|
||||||
const body = (request.body as any) || {};
|
const status = body?.status || "RESOLVED";
|
||||||
const status = body.status || "RESOLVED";
|
const group = await correlationService.resolveGroup(
|
||||||
const group = await correlationService.resolveGroup(groupId, status);
|
groupId,
|
||||||
|
userId,
|
||||||
|
status
|
||||||
|
);
|
||||||
|
|
||||||
if (!group) {
|
if (!group) {
|
||||||
return reply.code(404).send({ error: "Correlation group not found" });
|
return reply.code(404).send({ error: "Correlation group not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.send(group);
|
||||||
}
|
}
|
||||||
|
);
|
||||||
return reply.send(group);
|
|
||||||
});
|
|
||||||
|
|
||||||
fastify.get("/alerts", async (request, reply) => {
|
fastify.get("/alerts", async (request, reply) => {
|
||||||
const userId = (request.user as { id: string })?.id;
|
const userId = getUserId(request);
|
||||||
|
if (!userId || userId === "anonymous") {
|
||||||
if (!userId) {
|
|
||||||
return reply.code(401).send({ error: "User not authenticated" });
|
return reply.code(401).send({ error: "User not authenticated" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = request.query as Record<string, string>;
|
const query = request.query as Record<string, string>;
|
||||||
const result = await correlationService.getCorrelatedAlerts({
|
const result = await correlationService.getCorrelatedAlerts({
|
||||||
userId,
|
userId,
|
||||||
source: query.source || undefined,
|
source: (query.source as any) || undefined,
|
||||||
category: query.category || undefined,
|
category: (query.category as any) || undefined,
|
||||||
severity: query.severity || undefined,
|
severity: (query.severity as any) || undefined,
|
||||||
timeWindowMinutes: query.timeWindow
|
timeWindowMinutes: query.timeWindow
|
||||||
? parseInt(query.timeWindow)
|
? parseInt(query.timeWindow)
|
||||||
: 60,
|
: 60,
|
||||||
@@ -80,72 +135,200 @@ export function correlationRoutes(fastify: FastifyInstance) {
|
|||||||
return reply.send(result);
|
return reply.send(result);
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.post("/ingest/darkwatch", async (request, reply) => {
|
fastify.post(
|
||||||
const body = request.body as any;
|
"/ingest/darkwatch",
|
||||||
const alert = await correlationService.ingestDarkWatchAlert(
|
{
|
||||||
body.userId,
|
schema: {
|
||||||
body.sourceAlertId,
|
body: {
|
||||||
{
|
type: "object",
|
||||||
exposureId: body.exposureId,
|
properties: {
|
||||||
breachName: body.breachName,
|
sourceAlertId: { type: "string" },
|
||||||
severity: body.severity,
|
exposureId: { type: "string" },
|
||||||
channel: body.channel,
|
breachName: { type: "string", maxLength: 500 },
|
||||||
dataType: body.dataType,
|
severity: { type: "string", maxLength: 20 },
|
||||||
dataSource: body.dataSource,
|
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 Record<string, unknown>;
|
||||||
const body = request.body as any;
|
const alert = await correlationService.ingestDarkWatchAlert(
|
||||||
const alert = await correlationService.ingestSpamShieldAlert(
|
userId,
|
||||||
body.userId,
|
body.sourceAlertId as string,
|
||||||
body.sourceAlertId,
|
{
|
||||||
{
|
exposureId: body.exposureId as string,
|
||||||
phoneNumber: body.phoneNumber,
|
breachName: body.breachName as string,
|
||||||
decision: body.decision,
|
severity: body.severity as string,
|
||||||
confidence: body.confidence,
|
channel: body.channel as string,
|
||||||
reasons: body.reasons,
|
dataType: body.dataType as string[] | undefined,
|
||||||
channel: body.channel,
|
dataSource: body.dataSource as string | undefined,
|
||||||
hiyaReputationScore: body.hiyaReputationScore,
|
}
|
||||||
truecallerSpamScore: body.truecallerSpamScore,
|
);
|
||||||
}
|
return reply.code(201).send(alert);
|
||||||
);
|
}
|
||||||
return reply.code(201).send(alert);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
fastify.post("/ingest/voiceprint", async (request, reply) => {
|
fastify.post(
|
||||||
const body = request.body as any;
|
"/ingest/spamshield",
|
||||||
const alert = await correlationService.ingestVoicePrintAlert(
|
{
|
||||||
body.userId,
|
schema: {
|
||||||
body.sourceAlertId,
|
body: {
|
||||||
{
|
type: "object",
|
||||||
jobId: body.jobId,
|
properties: {
|
||||||
verdict: body.verdict,
|
sourceAlertId: { type: "string" },
|
||||||
syntheticScore: body.syntheticScore,
|
phoneNumber: { type: "string", maxLength: 20 },
|
||||||
confidence: body.confidence,
|
decision: { type: "string", enum: ["BLOCK", "FLAG", "ALLOW"] },
|
||||||
matchedEnrollmentId: body.matchedEnrollmentId,
|
confidence: { type: "number", minimum: 0, maximum: 1 },
|
||||||
matchedSimilarity: body.matchedSimilarity,
|
reasons: { type: "array", items: { type: "string" } },
|
||||||
analysisType: body.analysisType,
|
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 Record<string, unknown>;
|
||||||
const body = request.body as any;
|
const alert = await correlationService.ingestSpamShieldAlert(
|
||||||
const alert = await correlationService.ingestCallAnalysisAlert(
|
userId,
|
||||||
body.userId,
|
body.sourceAlertId as string,
|
||||||
body.sourceAlertId,
|
{
|
||||||
{
|
phoneNumber: body.phoneNumber as string,
|
||||||
callId: body.callId,
|
decision: body.decision as string,
|
||||||
eventType: body.eventType,
|
confidence: body.confidence as number,
|
||||||
mosScore: body.mosScore,
|
reasons: body.reasons as string[] | undefined,
|
||||||
anomaly: body.anomaly,
|
channel: body.channel as "call" | "sms" | undefined,
|
||||||
sentiment: body.sentiment,
|
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 helmet from "@fastify/helmet";
|
||||||
import sensible from "@fastify/sensible";
|
import sensible from "@fastify/sensible";
|
||||||
import { extractOrGenerateRequestId } from "@shieldai/types";
|
import { extractOrGenerateRequestId } from "@shieldai/types";
|
||||||
import { darkwatchRoutes, voiceprintRoutes, 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({
|
const app = Fastify({
|
||||||
logger: {
|
logger: {
|
||||||
@@ -12,10 +15,13 @@ const app = Fastify({
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function bootstrap() {
|
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(helmet);
|
||||||
await app.register(sensible);
|
await app.register(sensible);
|
||||||
|
|
||||||
|
// Register auth middleware to populate request.user
|
||||||
|
await app.register(authMiddleware);
|
||||||
|
|
||||||
app.addHook("onRequest", async (request, _reply) => {
|
app.addHook("onRequest", async (request, _reply) => {
|
||||||
const requestId = extractOrGenerateRequestId(request.headers);
|
const requestId = extractOrGenerateRequestId(request.headers);
|
||||||
request.id = requestId;
|
request.id = requestId;
|
||||||
|
|||||||
@@ -282,10 +282,11 @@ export class CorrelationEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async getGroupById(
|
public async getGroupById(
|
||||||
groupId: string
|
groupId: string,
|
||||||
|
userId: string
|
||||||
): Promise<CorrelationGroupOutput | null> {
|
): Promise<CorrelationGroupOutput | null> {
|
||||||
const group = await (prisma as any).correlationGroup.findUnique({
|
const group = await (prisma as any).correlationGroup.findUnique({
|
||||||
where: { id: groupId },
|
where: { id: groupId, userId },
|
||||||
include: {
|
include: {
|
||||||
alerts: {
|
alerts: {
|
||||||
orderBy: { createdAt: "asc" },
|
orderBy: { createdAt: "asc" },
|
||||||
@@ -298,10 +299,11 @@ export class CorrelationEngine {
|
|||||||
|
|
||||||
public async resolveGroup(
|
public async resolveGroup(
|
||||||
groupId: string,
|
groupId: string,
|
||||||
|
userId: string,
|
||||||
status: string = CorrelationStatus.RESOLVED
|
status: string = CorrelationStatus.RESOLVED
|
||||||
): Promise<CorrelationGroupOutput | null> {
|
): Promise<CorrelationGroupOutput | null> {
|
||||||
const group = await (prisma as any).correlationGroup.update({
|
const group = await (prisma as any).correlationGroup.update({
|
||||||
where: { id: groupId },
|
where: { id: groupId, userId },
|
||||||
data: {
|
data: {
|
||||||
status,
|
status,
|
||||||
resolvedAt: new Date(),
|
resolvedAt: new Date(),
|
||||||
|
|||||||
@@ -8,6 +8,24 @@ import {
|
|||||||
|
|
||||||
type EntityType = (typeof EntityTypes)[keyof typeof EntityTypes];
|
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 {
|
interface DarkWatchAlertPayload {
|
||||||
exposureId: string;
|
exposureId: string;
|
||||||
breachName: string;
|
breachName: string;
|
||||||
@@ -92,7 +110,7 @@ export class AlertNormalizer {
|
|||||||
: `Exposure detected in ${payload.breachName}`,
|
: `Exposure detected in ${payload.breachName}`,
|
||||||
entities,
|
entities,
|
||||||
sourceAlertId,
|
sourceAlertId,
|
||||||
payload: payload as unknown as Record<string, unknown>,
|
payload: sanitizePayload(payload as unknown as Record<string, unknown>),
|
||||||
timestamp,
|
timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -132,7 +150,7 @@ export class AlertNormalizer {
|
|||||||
: `SpamShield ${decision} decision with confidence ${Math.round(payload.confidence * 100)}%`,
|
: `SpamShield ${decision} decision with confidence ${Math.round(payload.confidence * 100)}%`,
|
||||||
entities,
|
entities,
|
||||||
sourceAlertId,
|
sourceAlertId,
|
||||||
payload: payload as unknown as Record<string, unknown>,
|
payload: sanitizePayload(payload as unknown as Record<string, unknown>),
|
||||||
timestamp,
|
timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -179,7 +197,7 @@ export class AlertNormalizer {
|
|||||||
: `Synthetic voice detection: ${verdict} (score: ${payload.syntheticScore.toFixed(3)})`,
|
: `Synthetic voice detection: ${verdict} (score: ${payload.syntheticScore.toFixed(3)})`,
|
||||||
entities,
|
entities,
|
||||||
sourceAlertId,
|
sourceAlertId,
|
||||||
payload: payload as unknown as Record<string, unknown>,
|
payload: sanitizePayload(payload as unknown as Record<string, unknown>),
|
||||||
timestamp,
|
timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -237,7 +255,7 @@ export class AlertNormalizer {
|
|||||||
description,
|
description,
|
||||||
entities,
|
entities,
|
||||||
sourceAlertId,
|
sourceAlertId,
|
||||||
payload: payload as unknown as Record<string, unknown>,
|
payload: sanitizePayload(payload as unknown as Record<string, unknown>),
|
||||||
timestamp,
|
timestamp,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,12 +126,12 @@ export class CorrelationService {
|
|||||||
return this.engine.getCorrelationGroups(query);
|
return this.engine.getCorrelationGroups(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getGroupById(groupId: string) {
|
public getGroupById(groupId: string, userId: string) {
|
||||||
return this.engine.getGroupById(groupId);
|
return this.engine.getGroupById(groupId, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public resolveGroup(groupId: string, status?: string) {
|
public resolveGroup(groupId: string, userId: string, status?: string) {
|
||||||
return this.engine.resolveGroup(groupId, status as any);
|
return this.engine.resolveGroup(groupId, userId, status as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
public getDashboardData(userId: string, timeWindowMinutes?: number) {
|
public getDashboardData(userId: string, timeWindowMinutes?: number) {
|
||||||
|
|||||||
@@ -23,16 +23,18 @@ model User {
|
|||||||
role UserRole @default(user)
|
role UserRole @default(user)
|
||||||
|
|
||||||
// Relationships
|
// Relationships
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
familyGroups FamilyGroupMember[]
|
familyGroups FamilyGroupMember[]
|
||||||
familyGroupOwned FamilyGroup[] @relation("FamilyGroupOwner")
|
familyGroupOwned FamilyGroup[] @relation("FamilyGroupOwner")
|
||||||
subscriptions Subscription[]
|
subscriptions Subscription[]
|
||||||
alerts Alert[]
|
alerts Alert[]
|
||||||
voiceEnrollments VoiceEnrollment[]
|
voiceEnrollments VoiceEnrollment[]
|
||||||
voiceAnalyses VoiceAnalysis[]
|
voiceAnalyses VoiceAnalysis[]
|
||||||
spamFeedback SpamFeedback[]
|
spamFeedback SpamFeedback[]
|
||||||
spamRules SpamRule[]
|
spamRules SpamRule[]
|
||||||
|
normalizedAlerts NormalizedAlert[]
|
||||||
|
correlationGroups CorrelationGroup[]
|
||||||
|
|
||||||
// Audit
|
// Audit
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
@@ -429,9 +431,93 @@ model KPISnapshot {
|
|||||||
metricName String
|
metricName String
|
||||||
metricValue Float
|
metricValue Float
|
||||||
metadata Json?
|
metadata Json?
|
||||||
|
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
@@index([metricName])
|
@@index([metricName])
|
||||||
@@index([date])
|
@@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",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"lint": "eslint src/"
|
"lint": "eslint src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -14,5 +15,9 @@
|
|||||||
"@shieldai/types": "workspace:*",
|
"@shieldai/types": "workspace:*",
|
||||||
"@shieldai/darkwatch": "workspace:*",
|
"@shieldai/darkwatch": "workspace:*",
|
||||||
"ioredis": "^5.4.0"
|
"ioredis": "^5.4.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "^4.1.5",
|
||||||
|
"@vitest/coverage-v8": "^4.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"lint": "eslint src/"
|
"lint": "eslint src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -20,7 +21,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/express": "^4.17.0",
|
"@types/express": "^4.17.0",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
"vitest": "^4.1.5"
|
"vitest": "^4.1.5",
|
||||||
|
"@vitest/coverage-v8": "^4.1.5"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"typescript": "^5.0.0"
|
"typescript": "^5.0.0"
|
||||||
|
|||||||
@@ -5,5 +5,18 @@ export default defineConfig({
|
|||||||
globals: true,
|
globals: true,
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
|
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",
|
"types": "src/index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"lint": "eslint src/",
|
"lint": "eslint src/",
|
||||||
"test": "vitest"
|
"test": "vitest",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"vitest": "^1.3.1"
|
"vitest": "^4.1.5",
|
||||||
|
"@vitest/coverage-v8": "^4.1.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"lint": "eslint src/"
|
"lint": "eslint src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -14,6 +15,10 @@
|
|||||||
"@shieldai/correlation": "workspace:*",
|
"@shieldai/correlation": "workspace:*",
|
||||||
"node-cache": "^5.1.2"
|
"node-cache": "^5.1.2"
|
||||||
},
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "^4.1.5",
|
||||||
|
"@vitest/coverage-v8": "^4.1.5"
|
||||||
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"lint": "eslint src/",
|
"lint": "eslint src/",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -22,6 +23,8 @@
|
|||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.19.0",
|
||||||
"eslint": "^8.56.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,
|
globals: true,
|
||||||
environment: 'node',
|
environment: 'node',
|
||||||
include: ['src/**/*.test.ts', 'test/**/*.test.ts'],
|
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": {
|
"scripts": {
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage",
|
||||||
"lint": "eslint src/"
|
"lint": "eslint src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -14,6 +15,10 @@
|
|||||||
"@shieldai/correlation": "workspace:*",
|
"@shieldai/correlation": "workspace:*",
|
||||||
"node-cache": "^5.1.2"
|
"node-cache": "^5.1.2"
|
||||||
},
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vitest": "^4.1.5",
|
||||||
|
"@vitest/coverage-v8": "^4.1.5"
|
||||||
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,10 @@
|
|||||||
"dependsOn": ["^build"],
|
"dependsOn": ["^build"],
|
||||||
"outputs": ["coverage/**"]
|
"outputs": ["coverage/**"]
|
||||||
},
|
},
|
||||||
|
"test:coverage": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"outputs": ["coverage/**"]
|
||||||
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
"outputs": []
|
"outputs": []
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user