Files
ShieldAI/packages/api/src/services/spamshield/spamshield.service.ts
Michael Freno 24bc9c235f Consolidate @shieldai/db and @shieldsai/shared-db packages (FRE-4603)
- Merged singleton pattern + type exports from shared-db
- Kept FieldEncryptionService from original db package
- Upgraded to Prisma v6.2.0 (newer version)
- Adopted shared-db's complete schema for multi-service platform
- Updated 17 consumer imports across darkwatch, voiceprint, jobs, api
- Standardized on @shieldai/db namespace

Files changed:
- packages/db/package.json (v0.1.0 → v0.2.0)
- packages/db/src/index.ts (consolidated exports)
- packages/db/prisma/schema.prisma (merged schema)
- packages/db/prisma/seed.ts (updated for new schema)
- 17 consumer files updated

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-02 15:06:02 -04:00

482 lines
14 KiB
TypeScript

import { prisma, SpamFeedback } from '@shieldai/db';
import { spamShieldEnv, SpamDecision, spamFeatureFlags, defaultScores, metadataLimits } from './spamshield.config';
import { createHash } from 'crypto';
import { spamAuditLogger, hashPhoneNumber } from './spamshield.audit-logger';
// Number reputation service (Hiya API integration)
export class NumberReputationService {
/**
* Check number reputation using Hiya API
*/
async checkReputation(phoneNumber: string): Promise<{
isSpam: boolean;
confidence: number;
spamType?: string;
reportCount: number;
}> {
try {
// Only enable if feature flag is set
if (!spamFeatureFlags.enableNumberReputation) {
return {
isSpam: false,
confidence: 0.0,
reportCount: 0,
};
}
// TODO: Integrate with Hiya API
// const response = await fetch(`${spamShieldEnv.HIYA_API_URL}/lookup`, {
// headers: { 'X-API-Key': spamShieldEnv.HIYA_API_KEY },
// method: 'POST',
// body: JSON.stringify({ phone: phoneNumber }),
// });
// Simulated response for now
return {
isSpam: false,
confidence: defaultScores.defaultReputationLowConfidence,
spamType: undefined,
reportCount: 0,
};
} catch (error) {
console.error('Error checking number reputation:', error);
return {
isSpam: false,
confidence: defaultScores.defaultReputationConfidence,
reportCount: 0,
};
}
}
/**
* Check number against multiple reputation sources
*/
async checkMultiSource(phoneNumber: string): Promise<{
hiya: { isSpam: boolean; confidence: number };
truecaller: { isSpam: boolean; confidence: number } | null;
combinedScore: number;
}> {
// Only enable if feature flag is set
if (!spamFeatureFlags.enableMultipleSources) {
return {
hiya: { isSpam: false, confidence: defaultScores.defaultReputationConfidence },
truecaller: null,
combinedScore: defaultScores.defaultSpamScore,
};
}
const hiyaResult = await this.checkReputation(phoneNumber);
let truecallerResult: { isSpam: boolean; confidence: number } | null = null;
if (spamShieldEnv.TRUECALLER_API_KEY) {
// TODO: Integrate Truecaller
truecallerResult = {
isSpam: false,
confidence: defaultScores.defaultReputationConfidence,
};
}
// Weighted average: Hiya 70%, Truecaller 30%
const combinedScore = hiyaResult.confidence * defaultScores.hiyaWeightInCombinedScore +
(truecallerResult?.confidence ?? defaultScores.defaultReputationConfidence) * defaultScores.truecallerWeightInCombinedScore;
return {
hiya: { isSpam: hiyaResult.isSpam, confidence: hiyaResult.confidence },
truecaller: truecallerResult,
combinedScore,
};
}
}
// SMS content classifier (BERT-based)
export class SMSClassifierService {
private model: any = null; // BERT model placeholder
private _initPromise: Promise<void> | null = null;
/**
* Initialize the BERT model (thread-safe via promise deduplication)
*/
async initialize(): Promise<void> {
// TODO: Load BERT model from path
// this.model = await loadBERTModel(spamShieldEnv.BERT_MODEL_PATH);
console.log('SMS classifier initialized');
}
/**
* Ensures model is initialized before use. Concurrent callers
* await the same initialization promise to avoid race conditions.
*/
private async ensureInitialized(): Promise<void> {
if (this._initPromise) {
return this._initPromise;
}
this._initPromise = (async () => {
if (this.model) {
return;
}
await this.initialize();
})();
return this._initPromise;
}
/**
* Classify SMS text as spam or ham
*/
async classify(
smsText: string,
phoneNumber?: string
): Promise<{
isSpam: boolean;
confidence: number;
spamFeatures: string[];
}> {
// Only enable if feature flag is set
if (!spamFeatureFlags.enableMLClassifier) {
// Return basic feature-based classification
const features = this.extractFeatures(smsText);
const confidence = this.calculateConfidence(features);
const isSpam = confidence >= spamShieldEnv.SPAM_THRESHOLD_AUTO_BLOCK;
spamAuditLogger.logClassification({
type: 'sms',
phoneNumberHash: phoneNumber ? hashPhoneNumber(phoneNumber) : 'unknown',
decision: isSpam ? 'spam' : 'ham',
confidence,
reasons: features,
featureFlags: { enableMLClassifier: spamFeatureFlags.enableMLClassifier },
});
return {
isSpam,
confidence,
spamFeatures: features,
};
}
await this.ensureInitialized();
// Extract features
const features = this.extractFeatures(smsText);
// TODO: Run through BERT model
// const prediction = await this.model.predict(smsText);
// Simulated prediction
const confidence = this.calculateConfidence(features);
const isSpam = confidence >= spamShieldEnv.SPAM_THRESHOLD_AUTO_BLOCK;
spamAuditLogger.logClassification({
type: 'sms',
phoneNumberHash: phoneNumber ? hashPhoneNumber(phoneNumber) : 'unknown',
decision: isSpam ? 'spam' : 'ham',
confidence,
reasons: features,
featureFlags: { enableMLClassifier: spamFeatureFlags.enableMLClassifier },
});
return {
isSpam,
confidence,
spamFeatures: features,
};
}
private extractFeatures(text: string): string[] {
const features: string[] = [];
const lowerText = text.toLowerCase();
// URL presence
if (/(http|www)\./i.test(text)) {
features.push('url_present');
}
// Emoji density
const emojiCount = (text.match(/[\p{Emoji}]/gu) || []).length;
if (emojiCount / text.length > 0.1) {
features.push('high_emoji_density');
}
// Urgency keywords
const urgencyWords = ['now', 'urgent', 'limited', 'act fast', 'today'];
if (urgencyWords.some(word => lowerText.includes(word))) {
features.push('urgency_keyword');
}
// Excessive capitalization
if (/[A-Z]{3,}/.test(text)) {
features.push('excessive_caps');
}
return features;
}
private calculateConfidence(features: string[]): number {
const baseConfidence = defaultScores.defaultBaseConfidence;
const featureWeights: Record<string, number> = {
url_present: defaultScores.featureWeights.urlPresent,
high_emoji_density: defaultScores.featureWeights.highEmojiDensity,
urgency_keyword: defaultScores.featureWeights.urgencyKeyword,
excessive_caps: defaultScores.featureWeights.excessiveCaps,
};
return Math.min(defaultScores.defaultMaxConfidence, baseConfidence +
features.reduce((sum, f) => sum + (featureWeights[f] || 0), 0));
}
}
// Call analysis service
export class CallAnalysisService {
/**
* Analyze incoming call for spam indicators
*/
async analyzeCall(callData: {
phoneNumber: string;
duration?: number;
callTime: Date;
isVoip?: boolean;
}): Promise<{
decision: SpamDecision;
confidence: number;
reasons: string[];
}> {
const reasons: string[] = [];
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 > defaultScores.highReputationThreshold) {
spamScore += reputation.combinedScore * defaultScores.reputationWeightInCombinedScore;
reasons.push('high_spam_reputation');
}
}
// Behavioral analysis - only if feature flag enabled
if (spamFeatureFlags.enableBehavioralAnalysis) {
if (callData.duration && callData.duration < 10) {
spamScore += defaultScores.shortDurationScore;
reasons.push('short_duration');
}
if (callData.isVoip) {
spamScore += defaultScores.voipScore;
reasons.push('voip_number');
}
// Time-of-day anomaly (simplified)
const hour = callData.callTime.getHours();
if (hour < 6 || hour > 22) {
spamScore += defaultScores.unusualHoursScore;
reasons.push('unusual_hours');
}
}
// Determine decision
let decision: SpamDecision;
if (spamScore >= spamShieldEnv.SPAM_THRESHOLD_AUTO_BLOCK) {
decision = SpamDecision.BLOCK;
} else if (spamScore >= spamShieldEnv.SPAM_THRESHOLD_FLAG) {
decision = SpamDecision.FLAG;
} else {
decision = SpamDecision.ALLOW;
}
spamAuditLogger.logClassification({
type: 'call',
phoneNumberHash: hashPhoneNumber(callData.phoneNumber),
decision: decision.toLowerCase() as 'block' | 'flag' | 'allow',
confidence: spamScore,
reasons,
featureFlags: {
enableBehavioralAnalysis: spamFeatureFlags.enableBehavioralAnalysis,
enableNumberReputation: spamFeatureFlags.enableNumberReputation,
},
metadata: {
duration: callData.duration,
isVoip: callData.isVoip,
callTime: callData.callTime.toISOString(),
},
});
return {
decision,
confidence: spamScore,
reasons,
};
}
}
// 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
*/
async recordFeedback(
userId: string,
phoneNumber: string,
isSpam: boolean,
confidence?: number,
metadata?: Record<string, any>
): Promise<SpamFeedback> {
// Defensive null checks for required fields
if (!userId || typeof userId !== 'string' || userId.trim().length === 0) {
throw new Error('Feedback: userId is required');
}
if (!phoneNumber || typeof phoneNumber !== 'string' || phoneNumber.trim().length === 0) {
throw new Error('Feedback: phoneNumber is required');
}
if (typeof isSpam !== 'boolean') {
throw new Error('Feedback: isSpam must be a boolean');
}
// Validate confidence range if provided
const validatedConfidence = confidence !== undefined && confidence !== null
? (Number.isFinite(confidence) && confidence >= 0 && confidence <= 1 ? confidence : undefined)
: undefined;
// Treat null metadata as undefined
const effectiveMetadata = metadata !== null ? metadata : undefined;
const validation = this.validateMetadata(effectiveMetadata);
const validatedMetadata = validation.trimmedMetadata;
// Only enable if feature flag is set
if (!spamFeatureFlags.enableCommunityIntelligence) {
// Return a mock feedback for development
return {
id: `mock_${Date.now()}`,
userId,
phoneNumber,
phoneNumberHash: this.hashPhoneNumber(phoneNumber),
isSpam,
confidence: validatedConfidence,
feedbackType: 'user_confirmation' as const,
metadata: validatedMetadata,
createdAt: new Date(),
updatedAt: new Date(),
};
}
const phoneNumberHash = this.hashPhoneNumber(phoneNumber);
const feedback = await prisma.spamFeedback.create({
data: {
userId,
phoneNumber,
phoneNumberHash,
isSpam,
confidence: validatedConfidence,
feedbackType: 'user_confirmation',
metadata: validatedMetadata,
},
});
return feedback;
}
/**
* Get spam history for a user
*/
async getSpamHistory(
userId: string,
options?: {
limit?: number;
isSpam?: boolean;
startDate?: Date;
}
): Promise<SpamFeedback[]> {
return prisma.spamFeedback.findMany({
where: {
userId,
...(options?.isSpam !== undefined && { isSpam: options.isSpam }),
...(options?.startDate && { createdAt: { gte: options.startDate } }),
},
orderBy: { createdAt: 'desc' },
take: options?.limit ?? 100,
});
}
/**
* Get statistics for a user
*/
async getStatistics(userId: string): Promise<{
totalAnalyses: number;
spamCount: number;
hamCount: number;
spamPercentage: number;
}> {
const [total, spam] = await Promise.all([
prisma.spamFeedback.count({ where: { userId } }),
prisma.spamFeedback.count({ where: { userId, isSpam: true } }),
]);
return {
totalAnalyses: total,
spamCount: spam,
hamCount: total - spam,
spamPercentage: total > 0 ? (spam / total) * 100 : 0,
};
}
private hashPhoneNumber(phoneNumber: string): string {
// SHA-256 hash for phone number fingerprinting
const hash = createHash('sha256').update(phoneNumber).digest('hex');
return `sha256_${hash}`;
}
}
// Export instances
export const numberReputationService = new NumberReputationService();
export const smsClassifierService = new SMSClassifierService();
export const callAnalysisService = new CallAnalysisService();
export const spamFeedbackService = new SpamFeedbackService();