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 = { 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; } }