Add ReDoS validation for SpamRule.pattern field (FRE-4512)

- Create regex-validation utility with ReDoS detection (nested quantifiers,
  overlapping alternations, complexity limits)
- Add @db.VarChar(500) constraint on pattern field in Prisma schema
- Integrate validation in rule-engine at load time and evaluation time
- Add 46 unit tests covering syntax, ReDoS patterns, complexity, edge cases

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-05-02 07:23:39 -04:00
parent e580a693c7
commit b01b79d02a
4 changed files with 620 additions and 12 deletions

View File

@@ -1,5 +1,6 @@
import { PrismaClient, SpamRule } from '@prisma/client';
import { generateRequestId } from '@shieldai/types';
import { validateRegexPattern, RegexValidationError } from '../utils/regex-validation';
export interface RuleMatch {
ruleId: string;
@@ -38,7 +39,7 @@ export class RuleEngine {
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) {
@@ -51,10 +52,24 @@ export class RuleEngine {
orderBy: { priority: 'desc' },
});
this.allRules = rules;
this.numberPatternRules = rules.filter(r => r.category === 'number_pattern');
this.behavioralRules = rules.filter(r => r.category === 'behavioral');
this.contentRules = rules.filter(r => r.category === 'content');
const validatedRules: SpamRule[] = [];
for (const rule of rules) {
try {
validateRegexPattern(rule.pattern);
validatedRules.push(rule);
} 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 = validatedRules;
this.numberPatternRules = validatedRules.filter(r => (r as any).category === 'number_pattern');
this.behavioralRules = validatedRules.filter(r => (r as any).category === 'behavioral');
this.contentRules = validatedRules.filter(r => (r as any).category === 'content');
this.lastLoadTime = now;
}
@@ -67,19 +82,24 @@ export class RuleEngine {
for (const rule of this.allRules) {
try {
validateRegexPattern(rule.pattern);
const pattern = new RegExp(rule.pattern);
if (pattern.test(phoneNumber)) {
matches.push({
ruleId: rule.id,
ruleName: rule.name,
pattern: rule.pattern,
score: rule.score,
priority: rule.priority as 'high' | 'medium' | 'low',
score: (rule as any).score,
priority: (rule as any).priority as 'high' | 'medium' | 'low',
matchedAt: new Date(),
});
}
} catch (error) {
console.error(`[RuleEngine] [req:${generateRequestId()}] Invalid pattern for rule ${rule.id}:`, error);
if (error instanceof RegexValidationError) {
console.warn(`[RuleEngine] [req:${generateRequestId()}] Rule "${rule.name}" (${rule.id}) ReDoS risk at eval: ${error.reason}`);
} else {
console.error(`[RuleEngine] [req:${generateRequestId()}] Invalid pattern for rule ${rule.id}:`, error);
}
}
}
@@ -95,19 +115,24 @@ export class RuleEngine {
for (const rule of this.contentRules) {
try {
validateRegexPattern(rule.pattern);
const pattern = new RegExp(rule.pattern, 'i');
if (pattern.test(smsBody)) {
matches.push({
ruleId: rule.id,
ruleName: rule.name,
pattern: rule.pattern,
score: rule.score,
priority: rule.priority as 'high' | 'medium' | 'low',
score: (rule as any).score,
priority: (rule as any).priority as 'high' | 'medium' | 'low',
matchedAt: new Date(),
});
}
} catch (error) {
console.error(`[RuleEngine] [req:${generateRequestId()}] Invalid pattern for rule ${rule.id}:`, error);
if (error instanceof RegexValidationError) {
console.warn(`[RuleEngine] [req:${generateRequestId()}] Rule "${rule.name}" (${rule.id}) ReDoS risk at eval: ${error.reason}`);
} else {
console.error(`[RuleEngine] [req:${generateRequestId()}] Invalid pattern for rule ${rule.id}:`, error);
}
}
}