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);
}
}
}

View File

@@ -0,0 +1,318 @@
export class RegexValidationError extends Error {
constructor(
public readonly pattern: string,
public readonly reason: string,
) {
super(`Regex validation failed for pattern "${pattern}": ${reason}`);
this.name = 'RegexValidationError';
}
}
export interface RegexValidationOptions {
maxLength?: number;
maxNestingDepth?: number;
maxAlternations?: number;
maxQuantifierLength?: number;
}
const DEFAULT_OPTIONS: Required<RegexValidationOptions> = {
maxLength: 500,
maxNestingDepth: 10,
maxAlternations: 20,
maxQuantifierLength: 100,
};
export interface RegexComplexityMetrics {
length: number;
nestingDepth: number;
alternationCount: number;
quantifierCount: number;
groupCount: number;
isRedosProne: boolean;
issues: string[];
}
function countChar(str: string, char: string): number {
let count = 0;
for (let i = 0; i < str.length; i++) {
if (str[i] === char) count++;
}
return count;
}
function calculateNestingDepth(pattern: string): number {
let maxDepth = 0;
let currentDepth = 0;
let inCharClass = false;
let escaped = false;
for (let i = 0; i < pattern.length; i++) {
const char = pattern[i];
if (escaped) {
escaped = false;
continue;
}
if (char === '\\') {
escaped = true;
continue;
}
if (char === '[') {
inCharClass = true;
continue;
}
if (char === ']' && inCharClass) {
inCharClass = false;
continue;
}
if (inCharClass) continue;
if (char === '(') {
currentDepth++;
if (currentDepth > maxDepth) {
maxDepth = currentDepth;
}
}
if (char === ')') {
currentDepth--;
}
}
return maxDepth;
}
function countAlternations(pattern: string): number {
let count = 0;
let inCharClass = false;
let escaped = false;
for (let i = 0; i < pattern.length; i++) {
const char = pattern[i];
if (escaped) {
escaped = false;
continue;
}
if (char === '\\') {
escaped = true;
continue;
}
if (char === '[') {
inCharClass = true;
continue;
}
if (char === ']' && inCharClass) {
inCharClass = false;
continue;
}
if (inCharClass) continue;
if (char === '|') {
count++;
}
}
return count;
}
function detectNestedQuantifiers(pattern: string): string[] {
const issues: string[] = [];
const quantifierRegex = /\(([^)]*)\)[*+?]/;
const overlappingRegex = /([a-zA-Z0-9])([^|]*?)\1/;
const groups = pattern.match(/\(([^)]+)\)/g) || [];
for (const group of groups) {
const innerContent = group.slice(1, -1);
if (innerContent.includes('+') || innerContent.includes('*') || innerContent.includes('?')) {
if (/[*+?]$/.test(group)) {
issues.push(`Nested quantifier detected in group: ${group}`);
}
}
}
const quantifierGroups = pattern.match(/\(([^)]+[*+?][^)]*)\)[*+?]/g) || [];
for (const g of quantifierGroups) {
if (!issues.includes(`Nested quantifier detected in group: ${g}`)) {
issues.push(`Nested quantifier detected in group: ${g}`);
}
}
return issues;
}
function detectOverlappingAlternations(pattern: string): string[] {
const issues: string[] = [];
const groups = pattern.match(/\(([^)]+)\)/g) || [];
for (const group of groups) {
const innerContent = group.slice(1, -1);
const alternations = innerContent.split('|');
if (alternations.length < 2) continue;
for (let i = 0; i < alternations.length; i++) {
for (let j = i + 1; j < alternations.length; j++) {
const a = alternations[i].trim();
const b = alternations[j].trim();
if (a && b && (a.startsWith(b) || b.startsWith(a))) {
issues.push(
`Overlapping alternation detected: "${a}" and "${b}" in group ${group}`
);
}
}
}
}
return issues;
}
function detectCatastrophicBacktracking(pattern: string): string[] {
const issues: string[] = [];
const starHeightPattern = /(\([^()]*\)[*+])+[*+]/;
if (starHeightPattern.test(pattern)) {
issues.push('High star-height pattern detected (potential exponential backtracking)');
}
const ambiguousQuantifiers = /[*+?][^)]*[*+?]/;
if (ambiguousQuantifiers.test(pattern)) {
const matches = pattern.match(/(?<=\()[^)]*(?=\))/g) || [];
for (const match of matches) {
if (ambiguousQuantifiers.test(match)) {
issues.push(`Ambiguous quantifiers in group content: ${match}`);
}
}
}
return issues;
}
function countQuantifiers(pattern: string): number {
let count = 0;
let escaped = false;
for (let i = 0; i < pattern.length; i++) {
if (escaped) {
escaped = false;
continue;
}
if (pattern[i] === '\\') {
escaped = true;
continue;
}
if (pattern[i] === '*' || pattern[i] === '+' || pattern[i] === '?') {
count++;
}
if (pattern[i] === '{') {
const closingBrace = pattern.indexOf('}', i);
if (closingBrace !== -1) {
count++;
i = closingBrace;
}
}
}
return count;
}
export function analyzeRegexComplexity(
pattern: string,
options?: RegexValidationOptions,
): RegexComplexityMetrics {
const opts = { ...DEFAULT_OPTIONS, ...options };
const issues: string[] = [];
const length = pattern.length;
const nestingDepth = calculateNestingDepth(pattern);
const alternationCount = countAlternations(pattern);
const quantifierCount = countQuantifiers(pattern);
const groupCount = countChar(pattern, '(');
if (length > opts.maxLength) {
issues.push(`Pattern length (${length}) exceeds maximum (${opts.maxLength})`);
}
if (nestingDepth > opts.maxNestingDepth) {
issues.push(`Nesting depth (${nestingDepth}) exceeds maximum (${opts.maxNestingDepth})`);
}
if (alternationCount > opts.maxAlternations) {
issues.push(`Alternation count (${alternationCount}) exceeds maximum (${opts.maxAlternations})`);
}
const nestedQuantifierIssues = detectNestedQuantifiers(pattern);
issues.push(...nestedQuantifierIssues);
const overlappingIssues = detectOverlappingAlternations(pattern);
issues.push(...overlappingIssues);
const backtrackingIssues = detectCatastrophicBacktracking(pattern);
issues.push(...backtrackingIssues);
return {
length,
nestingDepth,
alternationCount,
quantifierCount,
groupCount,
isRedosProne: issues.length > 0,
issues,
};
}
export function validateRegexPattern(
pattern: string,
options?: RegexValidationOptions,
): RegexComplexityMetrics {
if (!pattern || typeof pattern !== 'string') {
throw new RegexValidationError(pattern ?? '', 'Pattern must be a non-empty string');
}
try {
new RegExp(pattern);
} catch (err) {
throw new RegexValidationError(
pattern,
`Invalid regex syntax: ${(err as Error).message}`,
);
}
const metrics = analyzeRegexComplexity(pattern, options);
if (metrics.isRedosProne) {
throw new RegexValidationError(
pattern,
`ReDoS risk: ${metrics.issues.join('; ')}`,
);
}
return metrics;
}
export function isSafeRegexPattern(
pattern: string,
options?: RegexValidationOptions,
): boolean {
try {
validateRegexPattern(pattern, options);
return true;
} catch {
return false;
}
}