- S01 (High): Pre-compile regex patterns in RuleEngine.loadActiveRules() and
cache them; eliminate per-evaluation RegExp construction in rule-engine.ts
and spamshield.service.ts (ReDoS mitigation)
- S02 (High): SMS classifier now accepts optional senderPhoneNumber via
SmsClassificationContext; reputation check uses actual sender instead of
hardcoded 'placeholder'
- S03 (Medium): AlertServer (services/spamshield) now enforces JWT auth,
origin allowlist, and max client limit on WebSocket connections
- S04 (Medium): hashPhoneNumber() uses SHA-256 (crypto.createHash) instead
of reversible hex encoding (Buffer.toString('hex'))
- S05 (Medium): DecisionEngine.evaluate() wraps evaluation in Promise.race
with configurable evaluationTimeout; returns fallback decision on timeout
- S06 (Medium): CarrierFactory.getAllCarriers() is now async and properly
awaits isHealthy() promises instead of returning raw Promise objects
Co-Authored-By: Paperclip <noreply@paperclip.ing>
175 lines
5.1 KiB
TypeScript
175 lines
5.1 KiB
TypeScript
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<RuleEngineConfig> = {
|
|
loadIntervalMs: 60000,
|
|
enableCache: true,
|
|
cacheTtlMs: 300000,
|
|
};
|
|
|
|
export class RuleEngine {
|
|
private readonly config: Required<RuleEngineConfig>;
|
|
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<void> {
|
|
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<RuleMatch[]> {
|
|
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<RuleMatch[]> {
|
|
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<void> {
|
|
this.lastLoadTime = null;
|
|
await this.loadActiveRules();
|
|
}
|
|
|
|
clearCache(): void {
|
|
this.allRules = [];
|
|
this.numberPatternRules = [];
|
|
this.behavioralRules = [];
|
|
this.contentRules = [];
|
|
this.lastLoadTime = null;
|
|
}
|
|
|
|
getConfig(): Required<RuleEngineConfig> {
|
|
return { ...this.config };
|
|
}
|
|
}
|