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:
Senior Engineer
2026-05-02 18:36:29 -04:00
committed by Michael Freno
parent 0afdf8b6e8
commit 91e4985a8e
18 changed files with 491 additions and 126 deletions

View File

@@ -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);
}
);
}

View File

@@ -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;