diff --git a/apps/api/src/services/spamshield/spamshield.audit-logger.ts b/apps/api/src/services/spamshield/spamshield.audit-logger.ts new file mode 100644 index 000000000..dd62ee445 --- /dev/null +++ b/apps/api/src/services/spamshield/spamshield.audit-logger.ts @@ -0,0 +1,118 @@ +import { createHash } from 'crypto'; + +export type AuditClassificationType = 'sms' | 'call'; + +export interface AuditClassificationEntry { + id: string; + timestamp: string; + type: AuditClassificationType; + phoneNumberHash: string; + decision: 'spam' | 'ham' | 'block' | 'flag' | 'allow'; + confidence: number; + reasons: string[]; + featureFlags: Record; + metadata?: Record; +} + +const MAX_AUDIT_LOG_SIZE = 10_000; + +class AuditLogger { + private entries: AuditClassificationEntry[] = []; + + logClassification(entry: Omit): AuditClassificationEntry { + const record: AuditClassificationEntry = { + id: `audit-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, + timestamp: new Date().toISOString(), + ...entry, + }; + + this.entries.push(record); + + if (this.entries.length > MAX_AUDIT_LOG_SIZE) { + this.entries.shift(); + } + + console.log( + `[SpamShield:Audit] type=${record.type} decision=${record.decision} ` + + `confidence=${record.confidence.toFixed(3)} reasons=${record.reasons.join(',') || 'none'} ` + + `phoneHash=${record.phoneNumberHash}` + ); + + return record; + } + + getEntries( + filters?: { + type?: AuditClassificationType; + decision?: string; + startDate?: Date; + endDate?: Date; + limit?: number; + } + ): AuditClassificationEntry[] { + let results = this.entries; + + if (filters?.type) { + results = results.filter(e => e.type === filters.type); + } + + if (filters?.decision) { + results = results.filter(e => e.decision === filters.decision); + } + + if (filters?.startDate) { + results = results.filter(e => new Date(e.timestamp) >= filters.startDate!); + } + + if (filters?.endDate) { + results = results.filter(e => new Date(e.timestamp) <= filters.endDate!); + } + + if (filters?.limit) { + results = results.slice(-filters.limit); + } + + return results; + } + + getSummary(): { + totalEntries: number; + spamCount: number; + hamCount: number; + blockCount: number; + flagCount: number; + allowCount: number; + avgConfidence: number; + } { + const spamCount = this.entries.filter(e => e.decision === 'spam' || e.decision === 'block').length; + const hamCount = this.entries.filter(e => e.decision === 'ham' || e.decision === 'allow').length; + const blockCount = this.entries.filter(e => e.decision === 'block').length; + const flagCount = this.entries.filter(e => e.decision === 'flag').length; + const allowCount = this.entries.filter(e => e.decision === 'allow').length; + const avgConfidence = + this.entries.length > 0 + ? this.entries.reduce((s, e) => s + e.confidence, 0) / this.entries.length + : 0; + + return { + totalEntries: this.entries.length, + spamCount, + hamCount, + blockCount, + flagCount, + allowCount, + avgConfidence: Math.round(avgConfidence * 1000) / 1000, + }; + } + + clear(): void { + this.entries = []; + } +} + +export const spamAuditLogger = new AuditLogger(); + +export function hashPhoneNumber(phoneNumber: string): string { + const hash = createHash('sha256').update(phoneNumber.trim()).digest('hex'); + return `sha256_${hash}`; +} diff --git a/apps/api/src/services/spamshield/spamshield.service.ts b/apps/api/src/services/spamshield/spamshield.service.ts index 2cbaed271..b9a9dc339 100644 --- a/apps/api/src/services/spamshield/spamshield.service.ts +++ b/apps/api/src/services/spamshield/spamshield.service.ts @@ -1,6 +1,7 @@ import { prisma, SpamFeedback } from '@shieldsai/shared-db'; import { spamShieldEnv, SpamDecision, spamFeatureFlags } from './spamshield.config'; import { createHash } from 'crypto'; +import { spamAuditLogger, hashPhoneNumber } from './spamshield.audit-logger'; // Number reputation service (Hiya API integration) export class NumberReputationService { @@ -121,7 +122,10 @@ export class SMSClassifierService { /** * Classify SMS text as spam or ham */ - async classify(smsText: string): Promise<{ + async classify( + smsText: string, + phoneNumber?: string + ): Promise<{ isSpam: boolean; confidence: number; spamFeatures: string[]; @@ -133,6 +137,15 @@ export class SMSClassifierService { 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, @@ -144,14 +157,23 @@ export class SMSClassifierService { // 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, @@ -261,6 +283,23 @@ export class CallAnalysisService { 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,