import { PrismaClient, SpamRule } from '@prisma/client'; import { generateRequestId } from '@shieldai/types'; import { validateRegexPattern, RegexValidationError } from '../utils/regex-validation'; export interface CompiledRule { rule: SpamRule; compiledPattern: RegExp; compiledCaseInsensitive?: RegExp; } export interface RuleMatch { ruleId: string; ruleName: string; pattern: string; score: number; priority: 'high' | 'medium' | 'low'; matchedAt: Date; } export interface RuleEngineConfig { loadIntervalMs?: number; enableCache?: boolean; cacheTtlMs?: number; } const DEFAULT_CONFIG: Required = { loadIntervalMs: 60000, enableCache: true, cacheTtlMs: 300000, }; export class RuleEngine { private readonly config: Required; private numberPatternRules: CompiledRule[] = []; private behavioralRules: CompiledRule[] = []; private contentRules: CompiledRule[] = []; private allRules: CompiledRule[] = []; private lastLoadTime: Date | null = null; private readonly prisma: PrismaClient; constructor(prisma?: PrismaClient, config?: RuleEngineConfig) { this.prisma = prisma ?? new PrismaClient() as PrismaClient; this.config = { ...DEFAULT_CONFIG, ...config }; } async loadActiveRules(): Promise { const now = new Date(); if (this.config.enableCache && this.lastLoadTime) { const elapsed = now.getTime() - this.lastLoadTime.getTime(); if (elapsed < this.config.loadIntervalMs) { return; } } const rules = await this.prisma.spamRule.findMany({ where: { isActive: true }, orderBy: { priority: 'desc' }, }); const compiledRules: CompiledRule[] = []; for (const rule of rules) { try { validateRegexPattern(rule.pattern); const compiledPattern = new RegExp(rule.pattern); const compiledCaseInsensitive = new RegExp(rule.pattern, 'i'); compiledRules.push({ rule, compiledPattern, compiledCaseInsensitive, }); } catch (error) { if (error instanceof RegexValidationError) { console.warn(`[RuleEngine] [req:${generateRequestId()}] Rule "${rule.name}" (${rule.id}) ReDoS risk: ${error.reason}, skipping`); } else { console.error(`[RuleEngine] [req:${generateRequestId()}] Unexpected error validating rule "${rule.name}" (${rule.id}):`, error); } } } this.allRules = compiledRules; this.numberPatternRules = compiledRules.filter(r => (r.rule as any).category === 'number_pattern'); this.behavioralRules = compiledRules.filter(r => (r.rule as any).category === 'behavioral'); this.contentRules = compiledRules.filter(r => (r.rule as any).category === 'content'); this.lastLoadTime = now; } async evaluate(phoneNumber: string): Promise { if (this.allRules.length === 0) { await this.loadActiveRules(); } const matches: RuleMatch[] = []; for (const compiled of this.allRules) { try { if (compiled.compiledPattern.test(phoneNumber)) { matches.push({ ruleId: compiled.rule.id, ruleName: compiled.rule.name, pattern: compiled.rule.pattern, score: (compiled.rule as any).score, priority: (compiled.rule as any).priority as 'high' | 'medium' | 'low', matchedAt: new Date(), }); } } catch (error) { console.error(`[RuleEngine] [req:${generateRequestId()}] Evaluation error for rule ${compiled.rule.id}:`, error); } } return matches.sort((a, b) => b.score - a.score); } async evaluateSms(smsBody: string): Promise { if (this.contentRules.length === 0) { await this.loadActiveRules(); } const matches: RuleMatch[] = []; for (const compiled of this.contentRules) { try { if (compiled.compiledCaseInsensitive!.test(smsBody)) { matches.push({ ruleId: compiled.rule.id, ruleName: compiled.rule.name, pattern: compiled.rule.pattern, score: (compiled.rule as any).score, priority: (compiled.rule as any).priority as 'high' | 'medium' | 'low', matchedAt: new Date(), }); } } catch (error) { console.error(`[RuleEngine] [req:${generateRequestId()}] SMS evaluation error for rule ${compiled.rule.id}:`, error); } } return matches.sort((a, b) => b.score - a.score); } getNumberPatternRules(): SpamRule[] { return this.numberPatternRules.map(r => r.rule); } getBehavioralRules(): SpamRule[] { return this.behavioralRules.map(r => r.rule); } getContentRules(): SpamRule[] { return this.contentRules.map(r => r.rule); } getAllRules(): SpamRule[] { return this.allRules.map(r => r.rule); } async refreshRules(): Promise { this.lastLoadTime = null; await this.loadActiveRules(); } clearCache(): void { this.allRules = []; this.numberPatternRules = []; this.behavioralRules = []; this.contentRules = []; this.lastLoadTime = null; } getConfig(): Required { return { ...this.config }; } }