Auto-commit 2026-05-02 09:37
This commit is contained in:
@@ -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++;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
177
services/spamshield/src/middleware/spam-rate-limit.middleware.ts
Normal file
177
services/spamshield/src/middleware/spam-rate-limit.middleware.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user