- 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>
482 lines
14 KiB
TypeScript
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();
|