Auto-commit 2026-05-02 09:37

This commit is contained in:
2026-05-02 09:37:30 -04:00
parent b6b0f86d73
commit fe754761d9
15 changed files with 674 additions and 14 deletions

View File

@@ -1,4 +1,12 @@
import { SpamShieldService } from '../services/spamshield.service';
import {
HIGH_RISK_LINK_SCORE,
SHORT_AGGRESSIVE_SCORE,
EXCESSIVE_NUMBERS_SCORE,
URGENT_NEGATIVE_SCORE,
REPUTATION_SCORE_WEIGHT,
SMS_SPAM_THRESHOLD,
} from '../constants/sms-classifier.constants';
export interface SmsClassificationResult {
isSpam: boolean;
@@ -58,31 +66,31 @@ export class BertSmsClassifier implements SmsClassifier {
// High-risk patterns
if (hasLinks && length > 100) {
spamScore += 0.3;
spamScore += HIGH_RISK_LINK_SCORE;
}
// Short aggressive messages
if (length < 20 && hasNumbers) {
spamScore += 0.2;
spamScore += SHORT_AGGRESSIVE_SCORE;
}
// Excessive numbers
if (/\d{3,}/.test(text)) {
spamScore += 0.15;
spamScore += EXCESSIVE_NUMBERS_SCORE;
}
// Negative/urgent language
if (sentiment === 'negative' && language === 'unknown') {
spamScore += 0.2;
spamScore += URGENT_NEGATIVE_SCORE;
}
// Combine with reputation score if available
const reputation = await this.spamShield.checkReputation('placeholder');
if (reputation.isSpam) {
spamScore += 0.25;
spamScore += REPUTATION_SCORE_WEIGHT;
}
const isSpam = spamScore > 0.5;
const isSpam = spamScore > SMS_SPAM_THRESHOLD;
// Update metrics
this.metrics.totalClassified++;

View File

@@ -1,7 +1,16 @@
export const spamRateLimits = {
BASIC: 100,
PLUS: 500,
PREMIUM: 2000,
export type SubscriptionTier = 'BASIC' | 'PLUS' | 'PREMIUM';
export interface TierRateLimits {
perMinute: number;
perDay: number;
}
export type SubscriptionTierRateLimits = Record<SubscriptionTier, TierRateLimits>;
export const spamRateLimits: SubscriptionTierRateLimits = {
BASIC: { perMinute: 100, perDay: 1000 },
PLUS: { perMinute: 500, perDay: 5000 },
PREMIUM: { perMinute: 2000, perDay: 20000 },
} as const;
export const spamFeatureFlagDefaults = {
@@ -47,7 +56,27 @@ export const spamConfig = {
maxPhoneNumberLength: 20,
minPhoneNumberLength: 10,
defaultConfidenceThreshold: 0.7,
maxMetadataSize: 1024 * 10,
maxMetadataSize: 1024 * 4,
circuitBreakerThreshold: 5,
circuitBreakerTimeout: 60000,
} as const;
export const metadataLimits = {
maxMetadataSizeBytes: 4096,
maxMetadataKeys: 20,
maxMetadataValueSizeBytes: 512,
} as const;
/** Reputation and Spam Score Constants */
export const defaultReputationConfidence = 0.7;
export const defaultSpamScore = 0.0;
export const highReputationThreshold = 0.8;
export const lowReputationThreshold = 0.3;
/** Feature Weights for Reputation Scoring */
export const featureWeights = {
reputationWeight: 0.4,
ruleWeight: 0.3,
behavioralWeight: 0.2,
userHistoryWeight: 0.1,
} as const;

View File

@@ -0,0 +1,20 @@
/**
* SMS Classifier Constants
*
* Scoring weights and thresholds for SMS spam classification.
* These values control the contribution of different features to the final spam score.
*/
/** Feature Scoring Weights */
export const HIGH_RISK_LINK_SCORE = 0.3; // Links + long message (>100 chars)
export const SHORT_AGGRESSIVE_SCORE = 0.2; // Short message (<20 chars) with numbers
export const EXCESSIVE_NUMBERS_SCORE = 0.15; // Messages with 3+ digit numbers
export const URGENT_NEGATIVE_SCORE = 0.2; // Negative sentiment + unknown language
export const REPUTATION_SCORE_WEIGHT = 0.25; // Reputation-based spam indicator
/** Classification Thresholds */
export const SMS_SPAM_THRESHOLD = 0.5; // Final score threshold for spam classification
/** Length Analysis */
export const OPTIMAL_SMS_LENGTH = 160; // Standard SMS character limit
export const MAX_LENGTH_BONUS = 0.3; // Maximum score from length overflow

View File

@@ -0,0 +1,177 @@
import { RedisService } from '@shieldai/shared-notifications';
import { TierRateLimits, SubscriptionTier, spamRateLimits } from '../config/spamshield.config';
export interface RateLimitStatus {
exceeded: boolean;
limit: number;
remaining: number;
resetAt: Date;
retryAfterSeconds: number;
}
export interface RateLimitOptions {
windowMs?: number;
dailyWindowMs?: number;
}
export class SpamRateLimitMiddleware {
private redisService: RedisService;
private options: RateLimitOptions;
constructor(redisService: RedisService, options?: RateLimitOptions) {
this.redisService = redisService;
this.options = {
windowMs: options?.windowMs || 60000,
dailyWindowMs: options?.dailyWindowMs || 86400000,
};
}
private getMinuteKey(userId: string, tier: SubscriptionTier): string {
const windowMs = this.options.windowMs ?? 60000;
const minuteTimestamp = Math.floor(Date.now() / windowMs);
return `ratelimit:spam:${userId}:${tier}:min:${minuteTimestamp}`;
}
private getDayKey(userId: string, tier: SubscriptionTier): string {
const date = new Date().toISOString().split('T')[0];
return `ratelimit:spam:${userId}:${tier}:day:${date}`;
}
private getResetTime(windowMs: number): Date {
const now = Date.now();
const resetTimestamp = Math.ceil(now / windowMs) * windowMs;
return new Date(resetTimestamp);
}
async checkLimit(
userId: string,
tier: SubscriptionTier,
): Promise<RateLimitStatus> {
const tierLimits = spamRateLimits[tier];
const minuteKey = this.getMinuteKey(userId, tier);
const dayKey = this.getDayKey(userId, tier);
try {
const [minuteCount, dayCount] = await Promise.all([
this.redisService.getCounter(minuteKey),
this.redisService.getCounter(dayKey),
]);
const minuteExceeded = minuteCount >= tierLimits.perMinute;
const dayExceeded = dayCount >= tierLimits.perDay;
const exceeded = minuteExceeded || dayExceeded;
const effectiveLimit = exceeded
? Math.min(tierLimits.perMinute, tierLimits.perDay)
: Math.min(tierLimits.perMinute, tierLimits.perDay);
const effectiveCount = exceeded
? Math.min(minuteCount, dayCount)
: Math.min(minuteCount, dayCount);
const windowMs = this.options.windowMs ?? 60000;
return {
exceeded,
limit: effectiveLimit,
remaining: Math.max(0, effectiveLimit - effectiveCount),
resetAt: this.getResetTime(windowMs),
retryAfterSeconds: Math.ceil(
(this.getResetTime(windowMs).getTime() - Date.now()) / 1000,
),
};
} catch (error) {
console.error('[SpamRateLimit] Redis error:', error);
const windowMs = this.options.windowMs ?? 60000;
return {
exceeded: false,
limit: tierLimits.perMinute,
remaining: tierLimits.perMinute,
resetAt: this.getResetTime(windowMs),
retryAfterSeconds: windowMs / 1000,
};
}
}
async incrementCounter(
userId: string,
tier: SubscriptionTier,
): Promise<{ minuteCount: number; dayCount: number }> {
const minuteKey = this.getMinuteKey(userId, tier);
const dayKey = this.getDayKey(userId, tier);
try {
const windowMs = this.options.windowMs ?? 60000;
const dailyWindowMs = this.options.dailyWindowMs ?? 86400000;
const [minuteCount, dayCount] = await Promise.all([
this.redisService.increment(minuteKey, Math.ceil(windowMs / 1000)),
this.redisService.increment(dayKey, Math.ceil(dailyWindowMs / 1000)),
]);
return { minuteCount, dayCount };
} catch (error) {
console.error('[SpamRateLimit] Increment error:', error);
return { minuteCount: 0, dayCount: 0 };
}
}
async checkAndIncrement(
userId: string,
tier: SubscriptionTier,
): Promise<{ allowed: boolean; status: RateLimitStatus }> {
const status = await this.checkLimit(userId, tier);
if (!status.exceeded) {
await this.incrementCounter(userId, tier);
const updatedStatus = await this.checkLimit(userId, tier);
return { allowed: true, status: updatedStatus };
}
return { allowed: false, status };
}
async getUsage(userId: string, tier: SubscriptionTier): Promise<{
minuteUsed: number;
minuteLimit: number;
minuteRemaining: number;
dayUsed: number;
dayLimit: number;
dayRemaining: number;
}> {
const tierLimits = spamRateLimits[tier];
const minuteKey = this.getMinuteKey(userId, tier);
const dayKey = this.getDayKey(userId, tier);
try {
const [minuteCount, dayCount] = await Promise.all([
this.redisService.getCounter(minuteKey),
this.redisService.getCounter(dayKey),
]);
return {
minuteUsed: minuteCount,
minuteLimit: tierLimits.perMinute,
minuteRemaining: Math.max(0, tierLimits.perMinute - minuteCount),
dayUsed: dayCount,
dayLimit: tierLimits.perDay,
dayRemaining: Math.max(0, tierLimits.perDay - dayCount),
};
} catch (error) {
console.error('[SpamRateLimit] Usage fetch error:', error);
return {
minuteUsed: 0,
minuteLimit: tierLimits.perMinute,
minuteRemaining: tierLimits.perMinute,
dayUsed: 0,
dayLimit: tierLimits.perDay,
dayRemaining: tierLimits.perDay,
};
}
}
}
export function createSpamRateLimitMiddleware(
redisService: RedisService,
options?: RateLimitOptions,
): SpamRateLimitMiddleware {
return new SpamRateLimitMiddleware(redisService, options);
}

View File

@@ -2,7 +2,7 @@ import { PrismaClient, SpamFeedback, SpamRule, SpamAuditLog } from '@prisma/clie
import { FieldEncryptionService } from '@shieldai/db';
import { generateRequestId } from '@shieldai/types';
import { emitSpamShieldAlert } from '@shieldai/correlation';
import { spamConfig, spamFeatureFlags } from '../config/spamshield.config';
import { spamConfig, spamFeatureFlags, metadataLimits } from '../config/spamshield.config';
import { CircuitBreaker, CircuitBreakerError, CircuitState, CircuitBreakerMetrics } from '../circuit-breaker';
import { validatePhoneNumber as validateE164 } from '../utils/phone-validation';
import { CarrierApi, CarrierCall, CarrierSms, CarrierDecision } from '../carriers/carrier-types';
@@ -246,7 +246,8 @@ export class SpamShieldService {
userId: string,
phoneNumber: string,
isSpam: boolean,
label?: string
label?: string,
metadata?: Record<string, any>
): Promise<void> {
if (!spamFeatureFlags.enableFeedbackLoop) {
throw new Error('Feedback loop disabled via feature flag');
@@ -256,6 +257,10 @@ export class SpamShieldService {
const encrypted = FieldEncryptionService.encrypt(validated);
const hash = FieldEncryptionService.hashPhoneNumber(validated);
const validatedMetadata = metadata
? this.validateMetadata(metadata)
: { source: 'user_feedback' };
await prisma.spamFeedback.create({
data: {
userId,
@@ -263,7 +268,7 @@ export class SpamShieldService {
phoneNumberHash: hash,
isSpam,
label,
metadata: JSON.stringify({ source: 'user_feedback' }),
metadata: JSON.stringify(validatedMetadata),
},
});
}
@@ -543,4 +548,49 @@ export class SpamShieldService {
select: { id: true, pattern: true },
});
}
private validateMetadata(metadata: Record<string, any>): Record<string, any> {
const metadataStr = JSON.stringify(metadata);
if (metadataStr.length > metadataLimits.maxMetadataSizeBytes) {
console.log(`[SpamShield] Metadata size ${metadataStr.length}B exceeds limit ${metadataLimits.maxMetadataSizeBytes}B, truncating`);
}
const entries = Object.entries(metadata);
const truncatedEntries: [string, any][] = [];
for (let i = 0; i < Math.min(entries.length, metadataLimits.maxMetadataKeys); i++) {
const [key, value] = entries[i];
const valueStr = String(value);
if (valueStr.length > metadataLimits.maxMetadataValueSizeBytes) {
truncatedEntries.push([key, valueStr.slice(0, metadataLimits.maxMetadataValueSizeBytes)]);
} else {
truncatedEntries.push([key, value]);
}
}
const result = Object.fromEntries(truncatedEntries);
const resultStr = JSON.stringify(result);
if (resultStr.length > metadataLimits.maxMetadataSizeBytes) {
const shrunk: Record<string, any> = {};
let currentSize = 2;
for (const [key, value] of truncatedEntries) {
const entrySize = key.length + String(value).length + 3;
if (currentSize + entrySize <= metadataLimits.maxMetadataSizeBytes) {
shrunk[key] = value;
currentSize += entrySize;
} else {
break;
}
}
console.log(`[SpamShield] Metadata reduced to ${Object.keys(shrunk).length} keys to fit size limit`);
return shrunk;
}
return result;
}
}