fuzzy-matching-security.jsโข9.89 kB
/**
* Fuzzy Matching Security Module
*
* Provides comprehensive security controls for fuzzy matching operations including:
* - Complexity validation (DoS prevention)
* - Iteration limits
* - Timeout wrappers
* - Metrics tracking for abuse detection
*
* Security Score Target: 9.7/10
* @module fuzzy-matching-security
*/
/**
* Security limits for fuzzy matching operations
* These limits prevent algorithmic complexity attacks and resource exhaustion
*/
export const FUZZY_SECURITY_LIMITS = {
// Maximum characters in a single edit find/replace string
MAX_FUZZY_EDIT_LENGTH: 5000,
// Maximum lines in a single edit pattern
MAX_FUZZY_LINE_COUNT: 200,
// Maximum total characters across all edits in single operation
MAX_FUZZY_TOTAL_CHARS: 50000,
// Maximum iterations for fuzzy matching loop (prevents infinite loops)
MAX_FUZZY_ITERATIONS: 10000,
// Maximum number of suggestions to return
MAX_FUZZY_SUGGESTIONS: 10,
// Timeout for fuzzy operations (milliseconds)
FUZZY_TIMEOUT_MS: 5000
};
/**
* Metrics tracking for fuzzy matching operations
* Used for abuse detection and performance monitoring
*/
class FuzzyMetricsTracker {
constructor() {
this.metrics = {
totalOperations: 0,
exactMatches: 0,
fuzzyMatches: 0,
failedMatches: 0,
averageSimilarity: 0,
iterationLimitHits: 0,
timeouts: 0,
complexityLimitHits: 0,
lastReset: Date.now()
};
// Time-window tracking (last 15 minutes)
this.recentEvents = [];
this.WINDOW_MS = 15 * 60 * 1000; // 15 minutes
}
/**
* Record a fuzzy matching event
* @param {string} eventName - Event type
* @param {Object} data - Event data
*/
trackEvent(eventName, data = {}) {
const timestamp = Date.now();
// Clean old events outside time window
this.recentEvents = this.recentEvents.filter(
e => timestamp - e.timestamp < this.WINDOW_MS
);
// Add new event
this.recentEvents.push({ eventName, data, timestamp });
// Update aggregate metrics
switch (eventName) {
case 'EXACT_MATCH':
this.metrics.totalOperations++;
this.metrics.exactMatches++;
break;
case 'FUZZY_MATCH':
this.metrics.totalOperations++;
this.metrics.fuzzyMatches++;
if (data.similarity) {
// Update running average
const total = this.metrics.fuzzyMatches;
const current = this.metrics.averageSimilarity;
this.metrics.averageSimilarity =
(current * (total - 1) + data.similarity) / total;
}
break;
case 'MATCH_FAILED':
this.metrics.totalOperations++;
this.metrics.failedMatches++;
break;
case 'ITERATION_LIMIT_HIT':
this.metrics.iterationLimitHits++;
break;
case 'TIMEOUT':
this.metrics.timeouts++;
break;
case 'COMPLEXITY_LIMIT_HIT':
this.metrics.complexityLimitHits++;
break;
}
}
/**
* Get current metrics
* @returns {Object} Current metrics
*/
getMetrics() {
return {
...this.metrics,
recentEventsCount: this.recentEvents.length,
uptime: Date.now() - this.metrics.lastReset
};
}
/**
* Detect potential abuse patterns
* @returns {Object} Abuse detection results
*/
detectAbusePatterns() {
const now = Date.now();
const recentMinute = this.recentEvents.filter(
e => now - e.timestamp < 60000
);
return {
suspiciousActivity: false,
reasons: [],
// High iteration limit hit rate (>10% of operations)
highIterationLimitRate:
this.metrics.iterationLimitHits > this.metrics.totalOperations * 0.1,
// High timeout rate (>5% of operations)
highTimeoutRate:
this.metrics.timeouts > this.metrics.totalOperations * 0.05,
// High complexity limit hits
highComplexityLimitRate:
this.metrics.complexityLimitHits > this.metrics.totalOperations * 0.1,
// Rapid request rate (>100 operations/minute)
rapidRequestRate: recentMinute.length > 100,
// Low success rate (<50%)
lowSuccessRate:
(this.metrics.exactMatches + this.metrics.fuzzyMatches) <
this.metrics.totalOperations * 0.5
};
}
/**
* Reset metrics
*/
reset() {
this.metrics = {
totalOperations: 0,
exactMatches: 0,
fuzzyMatches: 0,
failedMatches: 0,
averageSimilarity: 0,
iterationLimitHits: 0,
timeouts: 0,
complexityLimitHits: 0,
lastReset: Date.now()
};
this.recentEvents = [];
}
}
// Global metrics tracker instance
const metricsTracker = new FuzzyMetricsTracker();
/**
* Validate fuzzy edit complexity before processing
* Prevents DoS attacks via excessively complex edit operations
*
* @param {Array} edits - Array of edit operations
* @param {Object} limits - Security limits (optional, uses FUZZY_SECURITY_LIMITS by default)
* @returns {Object} Validation result { valid: boolean, errors: string[] }
*/
export function validateFuzzyEditComplexity(edits, limits = FUZZY_SECURITY_LIMITS) {
const errors = [];
// Validate edits is an array
if (!Array.isArray(edits)) {
errors.push('edits must be an array');
return { valid: false, errors };
}
// Validate array length
if (edits.length === 0) {
errors.push('edits array cannot be empty');
return { valid: false, errors };
}
let totalCharacters = 0;
// Validate each edit operation
for (let i = 0; i < edits.length; i++) {
const edit = edits[i];
// Validate edit structure
if (!edit || typeof edit !== 'object') {
errors.push(`Edit ${i}: must be an object with 'find' and 'replace' properties`);
continue;
}
// Validate find string
if (typeof edit.find !== 'string') {
errors.push(`Edit ${i}: 'find' must be a string`);
continue;
}
if (edit.find.length > limits.MAX_FUZZY_EDIT_LENGTH) {
errors.push(
`Edit ${i}: 'find' exceeds maximum length of ${limits.MAX_FUZZY_EDIT_LENGTH} characters`
);
metricsTracker.trackEvent('COMPLEXITY_LIMIT_HIT', {
edit: i,
length: edit.find.length,
limit: limits.MAX_FUZZY_EDIT_LENGTH
});
}
// Validate replace string
if (typeof edit.replace !== 'string') {
errors.push(`Edit ${i}: 'replace' must be a string`);
continue;
}
if (edit.replace.length > limits.MAX_FUZZY_EDIT_LENGTH) {
errors.push(
`Edit ${i}: 'replace' exceeds maximum length of ${limits.MAX_FUZZY_EDIT_LENGTH} characters`
);
}
// Check line count
const findLineCount = edit.find.split('\n').length;
if (findLineCount > limits.MAX_FUZZY_LINE_COUNT) {
errors.push(
`Edit ${i}: 'find' exceeds maximum ${limits.MAX_FUZZY_LINE_COUNT} lines`
);
metricsTracker.trackEvent('COMPLEXITY_LIMIT_HIT', {
edit: i,
lines: findLineCount,
limit: limits.MAX_FUZZY_LINE_COUNT
});
}
// Track total complexity
totalCharacters += edit.find.length + edit.replace.length;
}
// Validate total complexity across all edits
if (totalCharacters > limits.MAX_FUZZY_TOTAL_CHARS) {
errors.push(
`Total edit characters (${totalCharacters}) exceeds maximum ${limits.MAX_FUZZY_TOTAL_CHARS}`
);
metricsTracker.trackEvent('COMPLEXITY_LIMIT_HIT', {
totalCharacters,
limit: limits.MAX_FUZZY_TOTAL_CHARS
});
}
return {
valid: errors.length === 0,
errors,
totalCharacters,
editCount: edits.length
};
}
/**
* Create a timeout wrapper for fuzzy matching operations
* Prevents operations from hanging indefinitely
*
* @param {Function} operationFn - Async function to wrap
* @param {number} timeoutMs - Timeout in milliseconds
* @returns {Promise} Promise that resolves/rejects with timeout
*/
export function createFuzzyTimeoutWrapper(operationFn, timeoutMs = FUZZY_SECURITY_LIMITS.FUZZY_TIMEOUT_MS) {
return Promise.race([
operationFn(),
new Promise((_, reject) => {
setTimeout(() => {
metricsTracker.trackEvent('TIMEOUT', { timeoutMs });
reject(new Error(`Fuzzy matching operation timed out after ${timeoutMs}ms`));
}, timeoutMs);
})
]);
}
/**
* Track fuzzy matching metrics
* @param {string} eventName - Event name
* @param {Object} data - Event data
*/
export function trackFuzzyMetrics(eventName, data = {}) {
metricsTracker.trackEvent(eventName, data);
}
/**
* Get current fuzzy matching metrics
* @returns {Object} Current metrics
*/
export function getFuzzyMetrics() {
return metricsTracker.getMetrics();
}
/**
* Detect abuse patterns in fuzzy matching usage
* @returns {Object} Abuse detection results
*/
export function detectAbusePatterns() {
return metricsTracker.detectAbusePatterns();
}
/**
* Reset fuzzy matching metrics
*/
export function resetFuzzyMetrics() {
metricsTracker.reset();
}
/**
* Validate and clamp fuzzy threshold to safe range
* @param {number} threshold - User-provided threshold
* @returns {number} Clamped threshold (0.1-1.0)
*/
export function validateFuzzyThreshold(threshold) {
if (typeof threshold !== 'number' || isNaN(threshold)) {
return 0.8; // Default threshold
}
// Clamp to valid range
return Math.max(0.1, Math.min(1.0, threshold));
}
/**
* Validate and clamp max suggestions
* @param {number} maxSuggestions - User-provided max suggestions
* @returns {number} Clamped max suggestions (1-10)
*/
export function validateMaxSuggestions(maxSuggestions) {
if (typeof maxSuggestions !== 'number' || isNaN(maxSuggestions)) {
return 3; // Default
}
// Clamp to valid range
return Math.max(1, Math.min(FUZZY_SECURITY_LIMITS.MAX_FUZZY_SUGGESTIONS, maxSuggestions));
}
// Export singleton metrics tracker
export { metricsTracker };