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

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

View File

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

View File

@@ -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"
}
}

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;

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

View File

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

View File

@@ -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"
}
}

View File

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

View File

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

View File

@@ -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"
}
}

View File

@@ -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"
}

View File

@@ -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"
}
}

View File

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

View File

@@ -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"
}

View File

@@ -15,6 +15,10 @@
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
},
"test:coverage": {
"dependsOn": ["^build"],
"outputs": ["coverage/**"]
},
"lint": {
"outputs": []
},