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 <noreply@paperclip.ing>
This commit is contained in:
118
apps/api/src/services/spamshield/spamshield.audit-logger.ts
Normal file
118
apps/api/src/services/spamshield/spamshield.audit-logger.ts
Normal file
@@ -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<string, boolean>;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const MAX_AUDIT_LOG_SIZE = 10_000;
|
||||
|
||||
class AuditLogger {
|
||||
private entries: AuditClassificationEntry[] = [];
|
||||
|
||||
logClassification(entry: Omit<AuditClassificationEntry, 'id' | 'timestamp'>): 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}`;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user