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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user