From b7600fa937bada7ae1c9bbbda29212c36ee76e31 Mon Sep 17 00:00:00 2001 From: Michael Freno Date: Thu, 30 Apr 2026 23:15:08 -0400 Subject: [PATCH] FRE-4511: Add audit trail logging for spam classification decisions Integrates spamAuditLogger into SMSClassifierService.classify() and CallAnalysisService.analyzeCall(). Each decision logs: - Classification type (sms/call), phone hash, decision, confidence - Feature flags active at time of classification - Decision rationale (feature list for SMS, reason codes for calls) Audit entries are queryable via spamAuditLogger.getEntries() with filters for type, decision, date range, and limit. Summary stats available via getSummary(). Co-Authored-By: Paperclip --- .../spamshield/spamshield.audit-logger.ts | 118 ++++++++++++++++++ .../services/spamshield/spamshield.service.ts | 45 ++++++- 2 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/services/spamshield/spamshield.audit-logger.ts 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,