Auto-commit 2026-05-02 09:37

This commit is contained in:
2026-05-02 09:37:34 -04:00
parent b7600fa937
commit 35d004cde3
3809 changed files with 2315945 additions and 106 deletions

1
apps/api/node_modules/.vite/vitest/results.json generated vendored Normal file
View File

@@ -0,0 +1 @@
{"version":"1.6.1","results":[[":src/__tests__/spam-rate-limit.test.ts",{"duration":41,"failed":false}]]}

View File

@@ -11,7 +11,9 @@
"dependencies": {
"@fastify/cors": "^11.2.0",
"@fastify/helmet": "^13.0.2",
"@shieldsai/shared-analytics": "*",
"@shieldsai/shared-auth": "*",
"@shieldsai/shared-billing": "*",
"@shieldsai/shared-db": "*",
"@shieldsai/shared-notifications": "*",
"@shieldsai/shared-utils": "*",

View File

@@ -2,6 +2,7 @@ import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { authMiddleware, AuthRequest } from './auth.middleware';
import { voiceprintRoutes } from './voiceprint.routes';
import { spamshieldRoutes } from './spamshield.routes';
import { darkwatchRoutes } from './darkwatch.routes';
export async function routes(fastify: FastifyInstance) {
// Authenticated routes group
@@ -130,4 +131,12 @@ export async function routes(fastify: FastifyInstance) {
},
{ prefix: '/spamshield' }
);
// DarkWatch service routes
fastify.register(
async (darkwatchRouter) => {
await darkwatchRoutes(darkwatchRouter);
},
{ prefix: '/darkwatch' }
);
}

View File

@@ -5,6 +5,7 @@ import {
callAnalysisService,
spamFeedbackService,
} from '../services/spamshield';
import { ErrorHandler, SpamErrorCode } from '../services/spamshield/spamshield.error-handler';
export async function spamshieldRoutes(fastify: FastifyInstance) {
// Classify SMS text
@@ -13,13 +14,19 @@ export async function spamshieldRoutes(fastify: FastifyInstance) {
const userId = authReq.user?.id;
if (!userId) {
return reply.code(401).send({ error: 'User ID required' });
ErrorHandler.send(reply, SpamErrorCode.UNAUTHORIZED, 'User ID required', { status: 401 });
return;
}
const body = request.body as { text: string };
if (!body.text || typeof body.text !== 'string') {
return reply.code(400).send({ error: 'text is required' });
const textValidation = ErrorHandler.validateRequiredField(body.text, 'text');
if (!textValidation.isValid && textValidation.error) {
ErrorHandler.send(reply, textValidation.error.code, textValidation.error.message, {
field: textValidation.error.field,
status: 400,
});
return;
}
try {
@@ -32,8 +39,9 @@ export async function spamshieldRoutes(fastify: FastifyInstance) {
},
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Classification failed';
return reply.code(422).send({ error: message });
ErrorHandler.send(reply, SpamErrorCode.CLASSIFICATION_FAILED, 'Classification failed', {
status: 422,
});
}
});
@@ -43,13 +51,19 @@ export async function spamshieldRoutes(fastify: FastifyInstance) {
const userId = authReq.user?.id;
if (!userId) {
return reply.code(401).send({ error: 'User ID required' });
ErrorHandler.send(reply, SpamErrorCode.UNAUTHORIZED, 'User ID required', { status: 401 });
return;
}
const body = request.body as { phoneNumber: string };
if (!body.phoneNumber || typeof body.phoneNumber !== 'string') {
return reply.code(400).send({ error: 'phoneNumber is required' });
const phoneValidation = ErrorHandler.validateRequiredField(body.phoneNumber, 'phoneNumber');
if (!phoneValidation.isValid && phoneValidation.error) {
ErrorHandler.send(reply, phoneValidation.error.code, phoneValidation.error.message, {
field: phoneValidation.error.field,
status: 400,
});
return;
}
try {
@@ -63,8 +77,9 @@ export async function spamshieldRoutes(fastify: FastifyInstance) {
},
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Reputation check failed';
return reply.code(422).send({ error: message });
ErrorHandler.send(reply, SpamErrorCode.REPUTATION_CHECK_FAILED, 'Reputation check failed', {
status: 422,
});
}
});
@@ -74,7 +89,8 @@ export async function spamshieldRoutes(fastify: FastifyInstance) {
const userId = authReq.user?.id;
if (!userId) {
return reply.code(401).send({ error: 'User ID required' });
ErrorHandler.send(reply, SpamErrorCode.UNAUTHORIZED, 'User ID required', { status: 401 });
return;
}
const body = request.body as {
@@ -84,8 +100,23 @@ export async function spamshieldRoutes(fastify: FastifyInstance) {
isVoip?: boolean;
};
if (!body.phoneNumber || !body.callTime) {
return reply.code(400).send({ error: 'phoneNumber and callTime are required' });
const phoneValidation = ErrorHandler.validateRequiredField(body.phoneNumber, 'phoneNumber');
const callTimeValidation = ErrorHandler.validateRequiredField(body.callTime, 'callTime');
if (!phoneValidation.isValid && phoneValidation.error) {
ErrorHandler.send(reply, phoneValidation.error.code, phoneValidation.error.message, {
field: phoneValidation.error.field,
status: 400,
});
return;
}
if (!callTimeValidation.isValid && callTimeValidation.error) {
ErrorHandler.send(reply, callTimeValidation.error.code, callTimeValidation.error.message, {
field: callTimeValidation.error.field,
status: 400,
});
return;
}
try {
@@ -103,8 +134,9 @@ export async function spamshieldRoutes(fastify: FastifyInstance) {
},
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Call analysis failed';
return reply.code(422).send({ error: message });
ErrorHandler.send(reply, SpamErrorCode.ANALYSIS_FAILED, 'Call analysis failed', {
status: 422,
});
}
});
@@ -114,7 +146,8 @@ export async function spamshieldRoutes(fastify: FastifyInstance) {
const userId = authReq.user?.id;
if (!userId) {
return reply.code(401).send({ error: 'User ID required' });
ErrorHandler.send(reply, SpamErrorCode.UNAUTHORIZED, 'User ID required', { status: 401 });
return;
}
const body = request.body as {
@@ -124,8 +157,22 @@ export async function spamshieldRoutes(fastify: FastifyInstance) {
metadata?: Record<string, unknown>;
};
if (!body.phoneNumber || typeof body.isSpam !== 'boolean') {
return reply.code(400).send({ error: 'phoneNumber and isSpam are required' });
const phoneValidation = ErrorHandler.validateRequiredField(body.phoneNumber, 'phoneNumber');
if (!phoneValidation.isValid && phoneValidation.error) {
ErrorHandler.send(reply, phoneValidation.error.code, phoneValidation.error.message, {
field: phoneValidation.error.field,
status: 400,
});
return;
}
const isSpamValidation = ErrorHandler.validateBooleanField(body.isSpam, 'isSpam');
if (!isSpamValidation.isValid && isSpamValidation.error) {
ErrorHandler.send(reply, isSpamValidation.error.code, isSpamValidation.error.message, {
field: isSpamValidation.error.field,
status: 400,
});
return;
}
try {
@@ -145,8 +192,9 @@ export async function spamshieldRoutes(fastify: FastifyInstance) {
},
});
} catch (error) {
const message = error instanceof Error ? error.message : 'Feedback recording failed';
return reply.code(422).send({ error: message });
ErrorHandler.send(reply, SpamErrorCode.FEEDBACK_RECORD_FAILED, 'Feedback recording failed', {
status: 422,
});
}
});
@@ -156,7 +204,8 @@ export async function spamshieldRoutes(fastify: FastifyInstance) {
const userId = authReq.user?.id;
if (!userId) {
return reply.code(401).send({ error: 'User ID required' });
ErrorHandler.send(reply, SpamErrorCode.UNAUTHORIZED, 'User ID required', { status: 401 });
return;
}
const query = request.query as {
@@ -187,15 +236,17 @@ export async function spamshieldRoutes(fastify: FastifyInstance) {
const userId = authReq.user?.id;
if (!userId) {
return reply.code(401).send({ error: 'User ID required' });
ErrorHandler.send(reply, SpamErrorCode.UNAUTHORIZED, 'User ID required', { status: 401 });
return;
}
try {
const stats = await spamFeedbackService.getStatistics(userId);
return reply.send({ statistics: stats });
} catch (error) {
const message = error instanceof Error ? error.message : 'Statistics retrieval failed';
return reply.code(422).send({ error: message });
ErrorHandler.send(reply, SpamErrorCode.ANALYSIS_FAILED, 'Statistics retrieval failed', {
status: 422,
});
}
});
}

View File

@@ -0,0 +1,174 @@
import { prisma, AlertType, AlertSeverity } from '@shieldsai/shared-db';
import {
NotificationService,
NotificationPriority,
loadNotificationConfig,
} from '@shieldsai/shared-notifications';
const ALERT_DEDUP_WINDOW_MS = 24 * 60 * 60 * 1000;
export class AlertPipeline {
private notificationService: NotificationService;
constructor() {
this.notificationService = new NotificationService(loadNotificationConfig());
}
async processNewExposures(exposureIds: string[]) {
const exposures = await prisma.exposure.findMany({
where: { id: { in: exposureIds }, isFirstTime: true },
include: {
subscription: {
select: {
id: true,
userId: true,
tier: true,
},
},
watchlistItem: true,
},
});
const alertsCreated: Awaited<ReturnType<typeof prisma.alert.create>>[] = [];
for (const exposure of exposures) {
const dedupKey = `exposure:${exposure.subscriptionId}:${exposure.source}:${exposure.identifierHash}`;
const recentAlert = await prisma.alert.findFirst({
where: {
subscriptionId: exposure.subscriptionId,
type: AlertType.exposure_detected,
createdAt: {
gte: new Date(Date.now() - ALERT_DEDUP_WINDOW_MS),
},
},
orderBy: { createdAt: 'desc' },
});
if (recentAlert) {
continue;
}
const alert = await prisma.alert.create({
data: {
subscriptionId: exposure.subscriptionId,
userId: exposure.subscription.userId,
exposureId: exposure.id,
type: AlertType.exposure_detected,
title: this.buildTitle(exposure),
message: this.buildMessage(exposure),
severity: this.mapSeverity(exposure.severity),
channel: this.getChannelsForTier(exposure.subscription.tier),
},
});
alertsCreated.push(alert);
await this.dispatchNotification(alert, exposure);
}
return alertsCreated;
}
async dispatchScanCompleteAlert(
subscriptionId: string,
userId: string,
exposuresFound: number
) {
const subscription = await prisma.subscription.findUnique({
where: { id: subscriptionId },
select: { tier: true },
});
if (!subscription) return;
const alert = await prisma.alert.create({
data: {
subscriptionId,
userId,
type: AlertType.scan_complete,
title: 'DarkWatch Scan Complete',
message: `Scan found ${exposuresFound} new exposure${exposuresFound === 1 ? '' : 's'}.`,
severity: exposuresFound > 0 ? 'warning' : 'info',
channel: this.getChannelsForTier(subscription.tier),
},
});
await this.dispatchNotification(alert, {
source: 'hibp',
severity: 'info',
identifier: '',
dataType: 'email',
} as any);
return alert;
}
private async dispatchNotification(
alert: {
userId: string;
channel: string[];
title: string;
message: string;
severity: AlertSeverity;
},
exposure: { source: string; severity: string; identifier: string; dataType: string }
) {
try {
if (!this.notificationService.isFullyConfigured()) return;
await this.notificationService.sendMultiChannelNotification(
{
userId: alert.userId,
},
alert.channel as any,
alert.title,
`<p>${alert.message}</p>
<p><strong>Source:</strong> ${exposure.source}</p>
<p><strong>Severity:</strong> ${exposure.severity}</p>
<p><strong>Type:</strong> ${exposure.dataType}</p>`,
alert.severity === 'critical'
? NotificationPriority.HIGH
: NotificationPriority.NORMAL
);
} catch (error) {
console.error('[AlertPipeline] Notification dispatch error:', error);
}
}
private buildTitle(exposure: {
source: string;
dataType: string;
severity: string;
}): string {
return `${exposure.severity.toUpperCase()}: ${exposure.dataType} exposure on ${exposure.source}`;
}
private buildMessage(exposure: {
identifier: string;
source: string;
severity: string;
dataType: string;
}): string {
const masked = exposure.identifier.includes('@')
? exposure.identifier.replace(/(?<=.{2}).*(?=@)/, '***')
: exposure.identifier.slice(0, 3) + '***';
return `Your ${exposure.dataType} (${masked}) was found in a ${exposure.source} breach with ${exposure.severity} severity.`;
}
private mapSeverity(severity: string): AlertSeverity {
return severity as AlertSeverity;
}
private getChannelsForTier(tier: string): string[] {
const channelMap: Record<string, string[]> = {
basic: ['email'],
plus: ['email', 'push'],
premium: ['email', 'push', 'sms'],
};
return channelMap[tier] || ['email'];
}
}
export const alertPipeline = new AlertPipeline();

View File

@@ -0,0 +1,5 @@
export { watchlistService } from './watchlist.service';
export { scanService } from './scan.service';
export { schedulerService } from './scheduler.service';
export { webhookService } from './webhook.service';
export { alertPipeline } from './alert.pipeline';

View File

@@ -0,0 +1,220 @@
import { prisma, ExposureSource, ExposureSeverity, WatchlistType } from '@shieldsai/shared-db';
import { createHash } from 'crypto';
function hashIdentifier(identifier: string): string {
return createHash('sha256').update(identifier.toLowerCase().trim()).digest('hex');
}
function determineSeverity(
source: ExposureSource,
dataType: WatchlistType
): ExposureSeverity {
const criticalSources = [ExposureSource.darkWebForum, ExposureSource.honeypot];
const warningSources = [ExposureSource.hibp, ExposureSource.shodan];
const criticalTypes = [WatchlistType.ssn];
if (criticalTypes.includes(dataType)) return ExposureSeverity.critical;
if (criticalSources.includes(source)) return ExposureSeverity.critical;
if (warningSources.includes(source)) return ExposureSeverity.warning;
return ExposureSeverity.info;
}
export class ScanService {
async checkHIBP(email: string): Promise<{ exposed: boolean; sources: string[] }> {
try {
const response = await fetch(
`https://hibp.com/api/v2/${encodeURIComponent(email)}`,
{
headers: {
'hibp-api-key': process.env.HIBP_API_KEY || '',
Accept: 'application/json',
},
signal: AbortSignal.timeout(15000),
}
);
if (response.status === 404) {
return { exposed: false, sources: [] };
}
if (!response.ok) {
console.error(`[ScanService:HIBP] Status ${response.status} for ${email}`);
return { exposed: false, sources: [] };
}
const data = await response.json();
const sources = Array.isArray(data)
? data.map((p: { Name: string }) => p.Name)
: [];
return { exposed: sources.length > 0, sources };
} catch (error) {
console.error('[ScanService:HIBP] Error:', error);
return { exposed: false, sources: [] };
}
}
async checkShodan(domain: string): Promise<{ exposed: boolean; ports: string[]; ips: string[] }> {
try {
const response = await fetch(
`https://api.shodan.io/shodan/host/${encodeURIComponent(domain)}`,
{
headers: {
Authorization: `Bearer ${process.env.SHODAN_API_KEY || ''}`,
},
signal: AbortSignal.timeout(15000),
}
);
if (response.status === 404) {
return { exposed: false, ports: [], ips: [] };
}
if (!response.ok) {
console.error(`[ScanService:Shodan] Status ${response.status} for ${domain}`);
return { exposed: false, ports: [], ips: [] };
}
const data = await response.json();
return {
exposed: !!data.ip_str,
ports: data.ports?.map(String) || [],
ips: [data.ip_str || ''],
};
} catch (error) {
console.error('[ScanService:Shodan] Error:', error);
return { exposed: false, ports: [], ips: [] };
}
}
async processSubscriptionScan(
subscriptionId: string,
watchlistItems: Awaited<ReturnType<ScanService['getWatchlistItems']>>
): Promise<{ exposuresCreated: number; exposuresUpdated: number }> {
let exposuresCreated = 0;
let exposuresUpdated = 0;
for (const item of watchlistItems) {
const identifier = item.value;
const identifierHash = hashIdentifier(identifier);
switch (item.type) {
case WatchlistType.email: {
const hibpResult = await this.checkHIBP(identifier);
if (hibpResult.exposed) {
for (const source of hibpResult.sources) {
const existing = await prisma.exposure.findFirst({
where: {
subscriptionId,
source: ExposureSource.hibp,
identifierHash,
metadata: { path: ['dbName'], equals: source },
},
});
if (existing) {
await prisma.exposure.update({
where: { id: existing.id },
data: { detectedAt: new Date() },
});
exposuresUpdated++;
} else {
await prisma.exposure.create({
data: {
subscriptionId,
watchlistItemId: item.id,
source: ExposureSource.hibp,
dataType: item.type,
identifier,
identifierHash,
severity: determineSeverity(ExposureSource.hibp, item.type),
isFirstTime: true,
metadata: { dbName: source },
detectedAt: new Date(),
},
});
exposuresCreated++;
}
}
}
break;
}
case WatchlistType.domain: {
const shodanResult = await this.checkShodan(identifier);
if (shodanResult.exposed) {
const existing = await prisma.exposure.findFirst({
where: {
subscriptionId,
source: ExposureSource.shodan,
identifierHash,
},
});
if (existing) {
await prisma.exposure.update({
where: { id: existing.id },
data: {
detectedAt: new Date(),
metadata: { ports: shodanResult.ports, ips: shodanResult.ips },
},
});
exposuresUpdated++;
} else {
await prisma.exposure.create({
data: {
subscriptionId,
watchlistItemId: item.id,
source: ExposureSource.shodan,
dataType: item.type,
identifier,
identifierHash,
severity: determineSeverity(ExposureSource.shodan, item.type),
isFirstTime: true,
metadata: { ports: shodanResult.ports, ips: shodanResult.ips },
detectedAt: new Date(),
},
});
exposuresCreated++;
}
}
break;
}
default: {
const existing = await prisma.exposure.findFirst({
where: { subscriptionId, watchlistItemId: item.id, identifierHash },
});
if (!existing) {
await prisma.exposure.create({
data: {
subscriptionId,
watchlistItemId: item.id,
source: ExposureSource.darkWebForum,
dataType: item.type,
identifier,
identifierHash,
severity: determineSeverity(ExposureSource.darkWebForum, item.type),
isFirstTime: true,
detectedAt: new Date(),
},
});
exposuresCreated++;
}
break;
}
}
}
return { exposuresCreated, exposuresUpdated };
}
async getWatchlistItems(subscriptionId: string) {
return prisma.watchlistItem.findMany({
where: { subscriptionId, isActive: true },
});
}
}
export const scanService = new ScanService();

View File

@@ -0,0 +1,97 @@
import { prisma, WatchlistType } from '@shieldsai/shared-db';
import { createHash } from 'crypto';
export function normalizeValue(type: WatchlistType, value: string): string {
const trimmed = value.trim().toLowerCase();
switch (type) {
case WatchlistType.email:
return trimmed.replace(/\s+/g, '');
case WatchlistType.phoneNumber:
return trimmed.replace(/[\s\-\(\)]/g, '');
case WatchlistType.ssn:
return trimmed.replace(/-/g, '');
case WatchlistType.address:
return trimmed;
case WatchlistType.domain:
return trimmed.replace(/^https?:\/\//, '').replace(/\/.*$/, '');
default:
return trimmed;
}
}
export function hashValue(value: string): string {
return createHash('sha256').update(value).digest('hex');
}
export class WatchlistService {
async addItem(
subscriptionId: string,
type: WatchlistType,
value: string,
maxItems: number
) {
const normalized = normalizeValue(type, value);
const itemHash = hashValue(normalized);
const currentCount = await prisma.watchlistItem.count({
where: { subscriptionId, isActive: true },
});
if (currentCount >= maxItems) {
throw new Error(
`Watchlist limit reached (${maxItems} items). Upgrade your plan to add more.`
);
}
const existing = await prisma.watchlistItem.findFirst({
where: { subscriptionId, type, hash: itemHash },
});
if (existing) {
if (!existing.isActive) {
return prisma.watchlistItem.update({
where: { id: existing.id },
data: { isActive: true },
});
}
return existing;
}
return prisma.watchlistItem.create({
data: {
subscriptionId,
type,
value: normalized,
hash: itemHash,
},
});
}
async getItems(subscriptionId: string) {
return prisma.watchlistItem.findMany({
where: { subscriptionId, isActive: true },
orderBy: { createdAt: 'desc' },
});
}
async removeItem(id: string, subscriptionId: string) {
return prisma.watchlistItem.update({
where: { id },
data: { isActive: false },
});
}
async getActiveItemsForScan(subscriptionId: string) {
return prisma.watchlistItem.findMany({
where: { subscriptionId, isActive: true },
});
}
async getItemCount(subscriptionId: string) {
return prisma.watchlistItem.count({
where: { subscriptionId, isActive: true },
});
}
}
export const watchlistService = new WatchlistService();

View File

@@ -74,3 +74,90 @@ export const spamRateLimits = {
analysesPerDay: 10000,
},
};
// Default confidence scores for spam detection layers
export const defaultScores = {
// Number reputation service defaults
defaultReputationConfidence: 0.0,
defaultReputationLowConfidence: 0.1,
// SMS classifier defaults
defaultBaseConfidence: 0.5,
defaultMaxConfidence: 1.0,
// Feature weights for SMS classification
featureWeights: {
urlPresent: 0.1,
highEmojiDensity: 0.15,
urgencyKeyword: 0.2,
excessiveCaps: 0.15,
},
// Call analysis defaults
defaultSpamScore: 0.0,
highReputationThreshold: 0.7,
reputationWeightInCombinedScore: 0.4,
shortDurationScore: 0.2,
voipScore: 0.15,
unusualHoursScore: 0.1,
// Source combination weights
hiyaWeightInCombinedScore: 0.7,
truecallerWeightInCombinedScore: 0.3,
};
// Metadata size limits for SpamFeedback
export const metadataLimits = {
// Maximum size for metadata JSON in bytes
maxMetadataSizeBytes: 4096,
// Maximum number of keys in metadata object
maxMetadataKeys: 20,
// Maximum size for individual metadata value in bytes
maxMetadataValueSizeBytes: 512,
};
// Standard error codes for spamshield API
export enum SpamErrorCode {
// Client errors (4xx)
INVALID_REQUEST = 'INVALID_REQUEST',
MISSING_REQUIRED_FIELD = 'MISSING_REQUIRED_FIELD',
UNAUTHORIZED = 'UNAUTHORIZED',
NOT_FOUND = 'NOT_FOUND',
VALIDATION_ERROR = 'VALIDATION_ERROR',
// Server errors (5xx)
CLASSIFICATION_FAILED = 'CLASSIFICATION_FAILED',
REPUTATION_CHECK_FAILED = 'REPUTATION_CHECK_FAILED',
ANALYSIS_FAILED = 'ANALYSIS_FAILED',
FEEDBACK_RECORD_FAILED = 'FEEDBACK_RECORD_FAILED',
DATABASE_ERROR = 'DATABASE_ERROR',
RATE_LIMIT_EXCEEDED = 'RATE_LIMIT_EXCEEDED',
SERVICE_UNAVAILABLE = 'SERVICE_UNAVAILABLE',
}
// Standard error response type
export interface SpamErrorResponse {
error: {
code: SpamErrorCode;
message: string;
field?: string;
timestamp: string;
requestId?: string;
};
}
// HTTP status code constants
export const HttpStatus = {
OK: 200,
CREATED: 201,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
UNPROCESSABLE_ENTITY: 422,
TOO_MANY_REQUESTS: 429,
INTERNAL_SERVER_ERROR: 500,
SERVICE_UNAVAILABLE: 503,
};

View File

@@ -0,0 +1,118 @@
import { FastifyReply } from 'fastify';
import { SpamErrorCode, HttpStatus, SpamErrorResponse } from './spamshield.config';
export { SpamErrorCode, HttpStatus };
export type { SpamErrorResponse };
/**
* Standardized error response builder for SpamShield API
*/
export class ErrorHandler {
/**
* Create a standard error response
*/
static create(
code: SpamErrorCode,
message: string,
options?: {
field?: string;
requestId?: string;
additionalData?: Record<string, unknown>;
}
): SpamErrorResponse {
return {
error: {
code,
message,
...(options?.field && { field: options.field }),
timestamp: new Date().toISOString(),
...(options?.requestId && { requestId: options.requestId }),
},
};
}
/**
* Send a standard error response with appropriate HTTP status code
*/
static send(
reply: FastifyReply,
code: SpamErrorCode,
message: string,
options?: {
field?: string;
status?: number;
requestId?: string;
}
): void {
const status = options?.status ?? this.getStatusForCode(code);
const errorResponse = this.create(code, message, {
field: options?.field,
requestId: options?.requestId,
});
reply.code(status).send(errorResponse);
}
/**
* Map error codes to HTTP status codes
*/
private static getStatusForCode(code: SpamErrorCode): number {
const statusMap: Record<SpamErrorCode, number> = {
// Client errors
[SpamErrorCode.INVALID_REQUEST]: HttpStatus.BAD_REQUEST,
[SpamErrorCode.MISSING_REQUIRED_FIELD]: HttpStatus.BAD_REQUEST,
[SpamErrorCode.UNAUTHORIZED]: HttpStatus.UNAUTHORIZED,
[SpamErrorCode.NOT_FOUND]: HttpStatus.NOT_FOUND,
[SpamErrorCode.VALIDATION_ERROR]: HttpStatus.BAD_REQUEST,
// Server errors
[SpamErrorCode.CLASSIFICATION_FAILED]: HttpStatus.UNPROCESSABLE_ENTITY,
[SpamErrorCode.REPUTATION_CHECK_FAILED]: HttpStatus.UNPROCESSABLE_ENTITY,
[SpamErrorCode.ANALYSIS_FAILED]: HttpStatus.UNPROCESSABLE_ENTITY,
[SpamErrorCode.FEEDBACK_RECORD_FAILED]: HttpStatus.UNPROCESSABLE_ENTITY,
[SpamErrorCode.DATABASE_ERROR]: HttpStatus.INTERNAL_SERVER_ERROR,
[SpamErrorCode.RATE_LIMIT_EXCEEDED]: HttpStatus.TOO_MANY_REQUESTS,
[SpamErrorCode.SERVICE_UNAVAILABLE]: HttpStatus.SERVICE_UNAVAILABLE,
};
return statusMap[code] ?? HttpStatus.INTERNAL_SERVER_ERROR;
}
/**
* Validate required string field
*/
static validateRequiredField(
value: unknown,
fieldName: string
): { isValid: boolean; error?: { code: SpamErrorCode; message: string; field: string } } {
if (!value || typeof value !== 'string' || value.trim() === '') {
return {
isValid: false,
error: {
code: SpamErrorCode.MISSING_REQUIRED_FIELD,
message: `${fieldName} is required`,
field: fieldName,
},
};
}
return { isValid: true };
}
/**
* Validate boolean field
*/
static validateBooleanField(
value: unknown,
fieldName: string
): { isValid: boolean; error?: { code: SpamErrorCode; message: string; field: string } } {
if (value === undefined || value === null || typeof value !== 'boolean') {
return {
isValid: false,
error: {
code: SpamErrorCode.VALIDATION_ERROR,
message: `${fieldName} must be a boolean`,
field: fieldName,
},
};
}
return { isValid: true };
}
}

View File

@@ -1,5 +1,5 @@
import { prisma, SpamFeedback } from '@shieldsai/shared-db';
import { spamShieldEnv, SpamDecision, spamFeatureFlags } from './spamshield.config';
import { spamShieldEnv, SpamDecision, spamFeatureFlags, defaultScores, metadataLimits } from './spamshield.config';
import { createHash } from 'crypto';
import { spamAuditLogger, hashPhoneNumber } from './spamshield.audit-logger';
@@ -34,7 +34,7 @@ export class NumberReputationService {
// Simulated response for now
return {
isSpam: false,
confidence: 0.1,
confidence: defaultScores.defaultReputationLowConfidence,
spamType: undefined,
reportCount: 0,
};
@@ -42,7 +42,7 @@ export class NumberReputationService {
console.error('Error checking number reputation:', error);
return {
isSpam: false,
confidence: 0.0,
confidence: defaultScores.defaultReputationConfidence,
reportCount: 0,
};
}
@@ -59,9 +59,9 @@ export class NumberReputationService {
// Only enable if feature flag is set
if (!spamFeatureFlags.enableMultipleSources) {
return {
hiya: { isSpam: false, confidence: 0.0 },
hiya: { isSpam: false, confidence: defaultScores.defaultReputationConfidence },
truecaller: null,
combinedScore: 0.0,
combinedScore: defaultScores.defaultSpamScore,
};
}
@@ -72,13 +72,13 @@ export class NumberReputationService {
// TODO: Integrate Truecaller
truecallerResult = {
isSpam: false,
confidence: 0.0,
confidence: defaultScores.defaultReputationConfidence,
};
}
// Weighted average: Hiya 70%, Truecaller 30%
const combinedScore = hiyaResult.confidence * 0.7 +
(truecallerResult?.confidence ?? 0) * 0.3;
const combinedScore = hiyaResult.confidence * defaultScores.hiyaWeightInCombinedScore +
(truecallerResult?.confidence ?? defaultScores.defaultReputationConfidence) * defaultScores.truecallerWeightInCombinedScore;
return {
hiya: { isSpam: hiyaResult.isSpam, confidence: hiyaResult.confidence },
@@ -211,15 +211,15 @@ export class SMSClassifierService {
}
private calculateConfidence(features: string[]): number {
const baseConfidence = 0.5;
const baseConfidence = defaultScores.defaultBaseConfidence;
const featureWeights: Record<string, number> = {
url_present: 0.1,
high_emoji_density: 0.15,
urgency_keyword: 0.2,
excessive_caps: 0.15,
url_present: defaultScores.featureWeights.urlPresent,
high_emoji_density: defaultScores.featureWeights.highEmojiDensity,
urgency_keyword: defaultScores.featureWeights.urgencyKeyword,
excessive_caps: defaultScores.featureWeights.excessiveCaps,
};
return Math.min(1.0, baseConfidence +
return Math.min(defaultScores.defaultMaxConfidence, baseConfidence +
features.reduce((sum, f) => sum + (featureWeights[f] || 0), 0));
}
}
@@ -240,15 +240,15 @@ export class CallAnalysisService {
reasons: string[];
}> {
const reasons: string[] = [];
let spamScore = 0.0;
let spamScore = defaultScores.defaultSpamScore;
// Number reputation check - only if feature flag enabled
if (spamFeatureFlags.enableBehavioralAnalysis) {
const reputationService = new NumberReputationService();
const reputation = await reputationService.checkMultiSource(callData.phoneNumber);
if (reputation.combinedScore > 0.7) {
spamScore += reputation.combinedScore * 0.4;
if (reputation.combinedScore > defaultScores.highReputationThreshold) {
spamScore += reputation.combinedScore * defaultScores.reputationWeightInCombinedScore;
reasons.push('high_spam_reputation');
}
}
@@ -256,19 +256,19 @@ export class CallAnalysisService {
// Behavioral analysis - only if feature flag enabled
if (spamFeatureFlags.enableBehavioralAnalysis) {
if (callData.duration && callData.duration < 10) {
spamScore += 0.2;
spamScore += defaultScores.shortDurationScore;
reasons.push('short_duration');
}
if (callData.isVoip) {
spamScore += 0.15;
spamScore += defaultScores.voipScore;
reasons.push('voip_number');
}
// Time-of-day anomaly (simplified)
const hour = callData.callTime.getHours();
if (hour < 6 || hour > 22) {
spamScore += 0.1;
spamScore += defaultScores.unusualHoursScore;
reasons.push('unusual_hours');
}
}
@@ -310,6 +310,52 @@ export class CallAnalysisService {
// User feedback service
export class SpamFeedbackService {
/**
* Validate metadata size against defined limits
*/
private validateMetadata(metadata?: Record<string, any>): {
isValid: boolean;
trimmedMetadata?: Record<string, any>;
reasons?: string[];
} {
if (!metadata) {
return { isValid: true };
}
const reasons: string[] = [];
let trimmedMetadata: Record<string, any> = metadata;
// Check number of keys
const keyCount = Object.keys(metadata).length;
if (keyCount > metadataLimits.maxMetadataKeys) {
reasons.push(`Metadata has ${keyCount} keys, exceeding limit of ${metadataLimits.maxMetadataKeys}`);
trimmedMetadata = Object.entries(metadata).slice(0, metadataLimits.maxMetadataKeys);
}
// Check total JSON size
const jsonSize = JSON.stringify(metadata).length;
if (jsonSize > metadataLimits.maxMetadataSizeBytes) {
reasons.push(`Metadata size ${jsonSize} bytes exceeds limit of ${metadataLimits.maxMetadataSizeBytes} bytes`);
// Truncate long values
trimmedMetadata = Object.fromEntries(
Object.entries(metadata).map(([key, value]) => {
const valueStr = String(value);
if (valueStr.length > metadataLimits.maxMetadataValueSizeBytes) {
return [key, valueStr.slice(0, metadataLimits.maxMetadataValueSizeBytes)];
}
return [key, value];
})
);
}
return {
isValid: reasons.length === 0,
trimmedMetadata,
reasons: reasons.length > 0 ? reasons : undefined,
};
}
/**
* Record user feedback on spam detection
*/
@@ -320,6 +366,10 @@ export class SpamFeedbackService {
confidence?: number,
metadata?: Record<string, any>
): Promise<SpamFeedback> {
// Validate metadata
const validation = this.validateMetadata(metadata);
const validatedMetadata = validation.trimmedMetadata;
// Only enable if feature flag is set
if (!spamFeatureFlags.enableCommunityIntelligence) {
// Return a mock feedback for development
@@ -331,7 +381,7 @@ export class SpamFeedbackService {
isSpam,
confidence,
feedbackType: 'user_confirmation' as const,
metadata,
metadata: validatedMetadata,
createdAt: new Date(),
updatedAt: new Date(),
};
@@ -347,7 +397,7 @@ export class SpamFeedbackService {
isSpam,
confidence,
feedbackType: 'user_confirmation',
metadata,
metadata: validatedMetadata,
},
});

12
apps/api/tsconfig.json Normal file
View File

@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}