FRE-4533: Merge apps/{api,web,mobile} and shared-db into ShieldAI repo
Merge FrenoCorp apps into ShieldAI packages/: - packages/api: merged routes (notifications), middleware (auth, rate-limit, error, logging), config, services (darkwatch, spamshield, voiceprint), tests - packages/web: new SolidJS web app stub - packages/mobile: new SolidJS mobile app stub - packages/shared-db: new Prisma DB package (separate from existing packages/db) - pnpm-workspace.yaml: restored (apps/* removed, already covered by packages/*) Next: reconcile packages/shared-db with packages/db, and fix server.ts correlationRoutes import
This commit is contained in:
462
packages/api/src/services/spamshield/spamshield.service.ts
Normal file
462
packages/api/src/services/spamshield/spamshield.service.ts
Normal file
@@ -0,0 +1,462 @@
|
||||
import { prisma, SpamFeedback } from '@shieldsai/shared-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> {
|
||||
// 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
|
||||
return {
|
||||
id: `mock_${Date.now()}`,
|
||||
userId,
|
||||
phoneNumber,
|
||||
phoneNumberHash: this.hashPhoneNumber(phoneNumber),
|
||||
isSpam,
|
||||
confidence,
|
||||
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,
|
||||
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();
|
||||
Reference in New Issue
Block a user