/** * Agentic QE v3 - CVE Prevention Tests * Tests for security vulnerability prevention utilities */ import { describe, it, expect } from 'vitest'; import { // Path traversal validatePath, normalizePath, joinPaths, getExtension, // ReDoS isRegexSafe, escapeRegex, createSafeRegex, // Timing-safe timingSafeCompare, timingSafeHashCompare, generateSecureToken, secureHash, // Input sanitization sanitizeInput, escapeHtml, stripHtmlTags, // Command injection validateCommand, escapeShellArg, } from '../../../../src/mcp/security/cve-prevention'; describe('Path Traversal Protection', () => { describe('validatePath', () => { it('should accept valid relative paths', () => { const result = validatePath('src/file.ts'); expect(result.valid).toBe(true); expect(result.normalizedPath).toBe('src/file.ts'); }); it('should reject basic path traversal', () => { const result = validatePath('../etc/passwd'); expect(result.valid).toBe(false); expect(result.riskLevel).toBe('critical'); expect(result.error).toContain('traversal'); }); it('should reject URL-encoded traversal', () => { const result = validatePath('%2e%2e/etc/passwd'); expect(result.valid).toBe(false); expect(result.riskLevel).toBe('critical'); }); it('should reject double-encoded traversal', () => { const result = validatePath('%252e%252e/etc/passwd'); expect(result.valid).toBe(false); }); it('should reject null byte injection', () => { const result = validatePath('file.txt\0.jpg'); expect(result.valid).toBe(false); }); it('should reject URL-encoded null byte', () => { const result = validatePath('file.txt%00.jpg'); expect(result.valid).toBe(false); }); it('should reject absolute paths by default', () => { const result = validatePath('/etc/passwd'); expect(result.valid).toBe(false); expect(result.error).toContain('Absolute paths'); }); it('should allow absolute paths when configured', () => { const result = validatePath('/home/user/file.txt', { allowAbsolute: true }); expect(result.valid).toBe(true); }); it('should reject dangerous system paths', () => { const paths = ['/etc/shadow', '/proc/self/environ', '/dev/urandom']; for (const path of paths) { const result = validatePath(path, { allowAbsolute: true }); expect(result.valid).toBe(false); expect(result.riskLevel).toBe('critical'); } }); it('should reject paths exceeding max length', () => { const longPath = 'a'.repeat(5000); const result = validatePath(longPath, { maxLength: 4096 }); expect(result.valid).toBe(false); }); it('should reject paths exceeding max depth', () => { const deepPath = 'a/b/c/d/e/f/g/h/i/j/k/l/m/n'; const result = validatePath(deepPath, { maxDepth: 5 }); expect(result.valid).toBe(false); }); it('should enforce allowed extensions', () => { const result = validatePath('file.exe', { allowedExtensions: ['.ts', '.js'], }); expect(result.valid).toBe(false); }); it('should enforce denied extensions', () => { const result = validatePath('script.exe'); expect(result.valid).toBe(false); expect(result.error).toContain('extension'); }); it('should validate within base path', () => { const result = validatePath('../../outside', { basePath: '/home/user/project' }); expect(result.valid).toBe(false); }); it('should accept paths within base path', () => { const result = validatePath('src/file.ts', { basePath: '/home/user/project' }); expect(result.valid).toBe(true); expect(result.normalizedPath).toBe('/home/user/project/src/file.ts'); }); }); describe('normalizePath', () => { it('should remove redundant slashes', () => { expect(normalizePath('a//b///c')).toBe('a/b/c'); }); it('should resolve . components', () => { expect(normalizePath('a/./b/./c')).toBe('a/b/c'); }); it('should resolve .. components safely', () => { expect(normalizePath('a/b/../c')).toBe('a/c'); }); it('should not go above root with ..', () => { expect(normalizePath('../../../a')).toBe('a'); }); it('should convert backslashes to forward slashes', () => { expect(normalizePath('a\\b\\c')).toBe('a/b/c'); }); }); describe('joinPaths', () => { it('should join path segments', () => { expect(joinPaths('a', 'b', 'c')).toBe('a/b/c'); }); it('should handle leading/trailing slashes', () => { expect(joinPaths('/a/', '/b/', '/c/')).toBe('a/b/c'); }); it('should filter empty segments', () => { expect(joinPaths('a', '', 'c')).toBe('a/c'); }); }); describe('getExtension', () => { it('should extract extension', () => { expect(getExtension('file.ts')).toBe('ts'); expect(getExtension('file.test.ts')).toBe('ts'); }); it('should return null for no extension', () => { expect(getExtension('file')).toBeNull(); }); it('should handle dotfiles', () => { expect(getExtension('.gitignore')).toBe('gitignore'); }); }); }); describe('ReDoS Prevention', () => { describe('isRegexSafe', () => { it('should accept safe patterns', () => { const safePatterns = [ '^[a-z]+$', '\\d{3}-\\d{4}', '[A-Z][a-z]*', 'hello|world', ]; for (const pattern of safePatterns) { const result = isRegexSafe(pattern); expect(result.safe).toBe(true); } }); it('should reject catastrophic backtracking patterns', () => { const dangerousPatterns = [ '(.*)+', // Classic ReDoS '(.+)+', '([a-z]+)+', '(a|a)+', ]; for (const pattern of dangerousPatterns) { const result = isRegexSafe(pattern); expect(result.safe).toBe(false); expect(result.riskyPatterns.length).toBeGreaterThan(0); } }); it('should reject nested quantifiers', () => { const result = isRegexSafe('((a+)+)+'); expect(result.safe).toBe(false); }); it('should detect exponential backtracking', () => { const result = isRegexSafe('([a-z]*)*'); expect(result.safe).toBe(false); }); }); describe('escapeRegex', () => { it('should escape special characters', () => { const input = 'a.b*c?d+e^f$g|h(i)j[k]l{m}n'; const escaped = escapeRegex(input); expect(escaped).toBe('a\\.b\\*c\\?d\\+e\\^f\\$g\\|h\\(i\\)j\\[k\\]l\\{m\\}n'); }); it('should allow safe regex from escaped input', () => { const userInput = 'file.*.ts'; const escaped = escapeRegex(userInput); const regex = new RegExp(escaped); expect(regex.test('file.*.ts')).toBe(true); expect(regex.test('file-any-ts')).toBe(false); }); }); describe('createSafeRegex', () => { it('should create regex for safe patterns', () => { const regex = createSafeRegex('^[a-z]+$'); expect(regex).not.toBeNull(); expect(regex?.test('hello')).toBe(true); }); it('should return null for unsafe patterns', () => { const regex = createSafeRegex('(.*)+'); expect(regex).toBeNull(); }); it('should return null for too-long patterns', () => { const longPattern = 'a'.repeat(20000); const regex = createSafeRegex(longPattern, undefined, 10000); expect(regex).toBeNull(); }); it('should return null for invalid regex syntax', () => { const regex = createSafeRegex('[invalid'); expect(regex).toBeNull(); }); }); }); describe('Timing-Safe Comparison', () => { describe('timingSafeCompare', () => { it('should return true for equal strings', () => { expect(timingSafeCompare('secret', 'secret')).toBe(true); }); it('should return false for different strings', () => { expect(timingSafeCompare('secret', 'Secret')).toBe(false); expect(timingSafeCompare('secret', 'secret1')).toBe(false); }); it('should handle different length strings', () => { expect(timingSafeCompare('short', 'longerstring')).toBe(false); }); it('should handle empty strings', () => { expect(timingSafeCompare('', '')).toBe(true); expect(timingSafeCompare('', 'something')).toBe(false); }); }); describe('timingSafeHashCompare', () => { it('should compare value against expected hash', () => { const value = 'my-secret-token'; const hash = secureHash(value); expect(timingSafeHashCompare(value, hash)).toBe(true); expect(timingSafeHashCompare('wrong-token', hash)).toBe(false); }); }); describe('generateSecureToken', () => { it('should generate tokens of specified length', () => { const token = generateSecureToken(32); // Base64 encoding makes it slightly longer expect(token.length).toBeGreaterThanOrEqual(32); }); it('should generate unique tokens', () => { const tokens = new Set(); for (let i = 0; i < 100; i++) { tokens.add(generateSecureToken(32)); } expect(tokens.size).toBe(100); }); it('should generate URL-safe tokens', () => { const token = generateSecureToken(64); expect(token).not.toMatch(/[+/=]/); }); }); describe('secureHash', () => { it('should produce consistent hashes', () => { const hash1 = secureHash('test'); const hash2 = secureHash('test'); expect(hash1).toBe(hash2); }); it('should produce different hashes for different inputs', () => { const hash1 = secureHash('test1'); const hash2 = secureHash('test2'); expect(hash1).not.toBe(hash2); }); it('should support salt', () => { const hash1 = secureHash('test', 'salt1'); const hash2 = secureHash('test', 'salt2'); expect(hash1).not.toBe(hash2); }); }); }); describe('Input Sanitization', () => { describe('sanitizeInput', () => { it('should trim whitespace', () => { expect(sanitizeInput(' hello ')).toBe('hello'); }); it('should enforce max length', () => { const long = 'a'.repeat(100); const result = sanitizeInput(long, { maxLength: 50 }); expect(result.length).toBe(50); }); it('should strip HTML by default', () => { expect(sanitizeInput('hello')).toBe('alert(1)hello'); }); it('should strip SQL injection attempts', () => { const inputs = [ "'; DROP TABLE users; --", "' OR '1'='1", '" OR "1"="1', 'UNION SELECT * FROM passwords', ]; for (const input of inputs) { const result = sanitizeInput(input); expect(result).not.toContain('DROP'); expect(result).not.toContain('UNION'); expect(result).not.toMatch(/OR\s+'1'\s*=\s*'1/i); } }); it('should remove shell metacharacters', () => { const result = sanitizeInput('hello; rm -rf /; echo world'); expect(result).not.toContain(';'); expect(result).not.toContain('|'); }); it('should filter to allowed characters', () => { const result = sanitizeInput('hello123!@#world', { allowedChars: /[a-z0-9]/, }); expect(result).toBe('hello123world'); }); }); describe('escapeHtml', () => { it('should escape HTML special characters', () => { expect(escapeHtml('"; const sanitized = sanitizeInput(input); expect(sanitized).not.toContain('')).not.toContain('script'); // Default command validation only allows safe commands expect(validateCommand('curl evil.com').valid).toBe(false); }); });