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

@@ -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(),

View File

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

View File

@@ -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) {