Skip to main content
Glama

MCP Shamash

custom-rule-engine.ts17.9 kB
import type { Finding } from '../types/index.js'; import * as fs from 'fs/promises'; import * as path from 'path'; import * as crypto from 'crypto'; export interface CustomRule { id: string; name: string; description: string; severity: 'critical' | 'high' | 'medium' | 'low' | 'informational'; category: 'security' | 'performance' | 'maintainability' | 'style'; pattern: string; filePatterns?: string[]; excludePatterns?: string[]; messageTemplate: string; remediation?: string; references?: string[]; enabled: boolean; createdBy?: string; createdAt: string; lastModified: string; } export interface RuleMatch { rule: CustomRule; file: string; line: number; column?: number; matchedText: string; context?: string; } export interface RuleEngineStats { totalRules: number; enabledRules: number; disabledRules: number; categoryCounts: Record<string, number>; severityCounts: Record<string, number>; } export class CustomRuleEngine { private rules: Map<string, CustomRule>; private rulesFilePath: string; constructor(projectRoot: string) { this.rules = new Map(); this.rulesFilePath = path.join(projectRoot, '.shamash', 'custom-rules.json'); } async loadRules(): Promise<void> { try { const content = await fs.readFile(this.rulesFilePath, 'utf-8'); const rulesData = JSON.parse(content); this.rules.clear(); if (rulesData.rules && Array.isArray(rulesData.rules)) { for (const rule of rulesData.rules) { this.rules.set(rule.id, rule); } } console.error(`Loaded ${this.rules.size} custom security rules`); } catch (error) { // File doesn't exist or is invalid, start with default rules await this.initializeDefaultRules(); } } private async initializeDefaultRules(): Promise<void> { const defaultRules: CustomRule[] = [ { id: 'hardcoded-api-key', name: 'Hardcoded API Key', description: 'Detects hardcoded API keys in source code', severity: 'high', category: 'security', pattern: '(api[_-]?key|apikey)\\s*[=:]\\s*["\'][a-zA-Z0-9]{20,}["\']', filePatterns: ['*.js', '*.ts', '*.py', '*.java', '*.go'], excludePatterns: ['test/**', 'tests/**', '**/*.test.*', '**/*.spec.*'], messageTemplate: 'Hardcoded API key detected: {matchedText}', remediation: 'Move API keys to environment variables or a secure configuration service', references: [ 'https://owasp.org/www-community/vulnerabilities/Use_of_hard-coded_password', ], enabled: true, createdAt: new Date().toISOString(), lastModified: new Date().toISOString(), }, { id: 'weak-password-hash', name: 'Weak Password Hashing', description: 'Detects use of weak password hashing algorithms', severity: 'high', category: 'security', pattern: '(md5|sha1)\\s*\\(', filePatterns: ['*.js', '*.ts', '*.py', '*.java', '*.php'], messageTemplate: 'Weak password hashing algorithm detected: {matchedText}', remediation: 'Use bcrypt, scrypt, or Argon2 for password hashing', references: [ 'https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html', ], enabled: true, createdAt: new Date().toISOString(), lastModified: new Date().toISOString(), }, { id: 'console-log-production', name: 'Console Log in Production', description: 'Detects console.log statements that should not be in production', severity: 'low', category: 'maintainability', pattern: 'console\\.(log|debug|info)\\s*\\(', filePatterns: ['*.js', '*.ts'], excludePatterns: ['test/**', 'tests/**', 'dev/**'], messageTemplate: 'Console statement detected: {matchedText}', remediation: 'Use proper logging library or remove console statements from production code', enabled: true, createdAt: new Date().toISOString(), lastModified: new Date().toISOString(), }, { id: 'sql-injection-risk', name: 'SQL Injection Risk', description: 'Detects potential SQL injection vulnerabilities', severity: 'critical', category: 'security', pattern: '(query|execute)\\s*\\(\\s*["\'][^"\']*\\+', filePatterns: ['*.js', '*.ts', '*.py', '*.java', '*.php'], messageTemplate: 'Potential SQL injection vulnerability: {matchedText}', remediation: 'Use parameterized queries or prepared statements', references: [ 'https://owasp.org/www-community/attacks/SQL_Injection', ], enabled: true, createdAt: new Date().toISOString(), lastModified: new Date().toISOString(), }, { id: 'insecure-random', name: 'Insecure Random Number Generation', description: 'Detects use of insecure random number generators', severity: 'medium', category: 'security', pattern: '(Math\\.random|random\\.randint|rand\\()', filePatterns: ['*.js', '*.ts', '*.py', '*.java'], messageTemplate: 'Insecure random number generation: {matchedText}', remediation: 'Use cryptographically secure random number generators', enabled: true, createdAt: new Date().toISOString(), lastModified: new Date().toISOString(), }, ]; for (const rule of defaultRules) { this.rules.set(rule.id, rule); } await this.saveRules(); console.error(`Initialized ${defaultRules.length} default custom rules`); } async saveRules(): Promise<void> { try { const rulesDir = path.dirname(this.rulesFilePath); await fs.mkdir(rulesDir, { recursive: true }); const rulesData = { version: '1.0', lastUpdated: new Date().toISOString(), rules: Array.from(this.rules.values()), }; await fs.writeFile(this.rulesFilePath, JSON.stringify(rulesData, null, 2), 'utf-8'); } catch (error) { console.error('Failed to save custom rules:', error); throw error; } } async scanWithCustomRules(targetPath: string): Promise<{ findings: Finding[]; tokenUsage: number }> { const findings: Finding[] = []; const enabledRules = Array.from(this.rules.values()).filter(r => r.enabled); if (enabledRules.length === 0) { return { findings, tokenUsage: 0 }; } console.error(`Running custom rule scan with ${enabledRules.length} rules`); try { // Get all files in the target path const files = await this.getTargetFiles(targetPath); // Scan each file with all rules for (const file of files) { const matches = await this.scanFile(file, enabledRules); for (const match of matches) { findings.push(this.convertMatchToFinding(match)); } } } catch (error) { console.error('Custom rule scan failed:', error); } return { findings, tokenUsage: Math.min(findings.length * 8 + 30, 200), // Minimal token usage for custom rules }; } private async getTargetFiles(targetPath: string): Promise<string[]> { const files: string[] = []; try { const entries = await fs.readdir(targetPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(targetPath, entry.name); // Skip common ignore patterns if (this.shouldSkipPath(entry.name)) { continue; } if (entry.isDirectory()) { const subFiles = await this.getTargetFiles(fullPath); files.push(...subFiles); } else if (entry.isFile()) { files.push(fullPath); } } } catch (error) { console.error(`Failed to read directory ${targetPath}:`, error); } return files; } private shouldSkipPath(name: string): boolean { const skipPatterns = [ 'node_modules', '.git', 'dist', 'build', 'target', 'coverage', '__pycache__', '.pytest_cache', '.nyc_output', 'vendor', '.venv', 'venv', '.env' ]; return skipPatterns.some(pattern => name.includes(pattern)); } private async scanFile(filePath: string, rules: CustomRule[]): Promise<RuleMatch[]> { const matches: RuleMatch[] = []; try { const content = await fs.readFile(filePath, 'utf-8'); const lines = content.split('\n'); for (const rule of rules) { // Check if file matches rule patterns if (!this.fileMatchesRule(filePath, rule)) { continue; } // Apply rule to file content const ruleMatches = this.applyRuleToContent(rule, content, lines, filePath); matches.push(...ruleMatches); } } catch (error) { // Skip files that can't be read (binary, permission issues, etc.) } return matches; } private fileMatchesRule(filePath: string, rule: CustomRule): boolean { const fileName = path.basename(filePath); const relativePath = filePath; // Check file patterns if (rule.filePatterns && rule.filePatterns.length > 0) { const matchesPattern = rule.filePatterns.some(pattern => { const regex = new RegExp(pattern.replace(/\*/g, '.*').replace(/\?/g, '.')); return regex.test(fileName); }); if (!matchesPattern) { return false; } } // Check exclude patterns if (rule.excludePatterns && rule.excludePatterns.length > 0) { const matchesExclude = rule.excludePatterns.some(pattern => { const regex = new RegExp(pattern.replace(/\*/g, '.*').replace(/\?/g, '.')); return regex.test(relativePath); }); if (matchesExclude) { return false; } } return true; } private applyRuleToContent(rule: CustomRule, content: string, lines: string[], filePath: string): RuleMatch[] { const matches: RuleMatch[] = []; try { // Check for ReDoS patterns before executing if (!this.isPatternSafe(rule.pattern)) { console.error(`Potentially dangerous ReDoS pattern detected in rule ${rule.id}: ${rule.pattern}`); return matches; } const regex = new RegExp(rule.pattern, 'gi'); let match; const startTime = Date.now(); const timeout = 5000; // 5 second timeout let iterationCount = 0; const maxIterations = 10000; // Prevent infinite loops while ((match = regex.exec(content)) !== null) { // Timeout protection if (Date.now() - startTime > timeout) { console.error(`Regex timeout for rule ${rule.id}: Pattern took longer than ${timeout}ms`); break; } // Iteration limit protection if (++iterationCount > maxIterations) { console.error(`Regex iteration limit exceeded for rule ${rule.id}: More than ${maxIterations} matches`); break; } // Prevent infinite loops with global flag if (match.index === regex.lastIndex) { regex.lastIndex++; } // Find line number const beforeMatch = content.substring(0, match.index); const lineNumber = beforeMatch.split('\n').length; const line = lines[lineNumber - 1]; const column = match.index - beforeMatch.lastIndexOf('\n') - 1; matches.push({ rule, file: filePath, line: lineNumber, column: Math.max(0, column), matchedText: match[0], context: line?.trim(), }); } } catch (error) { console.error(`Failed to apply rule ${rule.id}:`, error); } return matches; } private isPatternSafe(pattern: string): boolean { // Detect common ReDoS patterns that can cause exponential backtracking const dangerousPatterns = [ /\([^)]*\+[^)]*\)\+/, // (a+)+ - nested quantifiers /\([^)]*\*[^)]*\)\+/, // (a*)+ - nested quantifiers /\([^)]*\+[^)]*\)\*/, // (a+)* - nested quantifiers /\([^)]*\|[^)]*\)\+/, // (a|b)+ - alternation with quantifier (but not simple alternation) /\([^)]*\+[^)]*\)\{2,\}/, // (a+){2,} - nested quantifier ranges /\.\*\.\*\.\*/, // .*.*.* - triple wildcards pattern /\+\.\+\+/, // +.+ with additional + - triple plus quantifiers ]; // Check pattern length - very long patterns can be suspicious if (pattern.length > 1000) { return false; } // Check for nested quantifiers and other ReDoS patterns for (const dangerousPattern of dangerousPatterns) { if (dangerousPattern.test(pattern)) { return false; } } // Additional complexity checks - be more lenient const quantifierCount = (pattern.match(/[+*?{]/g) || []).length; const groupCount = (pattern.match(/\(/g) || []).length; // Too many quantifiers or groups can indicate complexity - increased limits if (quantifierCount > 15 || groupCount > 8) { return false; } // Check for specific dangerous nested quantifier combinations // Look for patterns like (.*)+, (.*)*, (+.*)+, etc. const reallyDangerousPatterns = [ /\(\.\*\)\+/, // (.*)+ /\(\.\*\)\*/, // (.*)* /\(\.\+\)\+/, // (.+)+ /\(\.\+\)\*/, // (.+)* ]; for (const dangerousPattern of reallyDangerousPatterns) { if (dangerousPattern.test(pattern)) { return false; } } return true; } private convertMatchToFinding(match: RuleMatch): Finding { const messageWithContext = match.rule.messageTemplate.replace( '{matchedText}', match.matchedText ); return { id: `custom_${match.rule.id}_${this.hashMatch(match)}`, type: 'custom', severity: match.rule.severity, title: `${match.rule.name}: ${messageWithContext}`, description: match.rule.description, location: { file: match.file, line: match.line, column: match.column, }, remediation: match.rule.remediation, }; } private hashMatch(match: RuleMatch): string { const data = `${match.rule.id}:${match.file}:${match.line}:${match.matchedText}`; return crypto.createHash('sha256').update(data).digest('hex').substring(0, 8); } async addRule(rule: Omit<CustomRule, 'id' | 'createdAt' | 'lastModified'>): Promise<string> { const id = `custom_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const timestamp = new Date().toISOString(); const newRule: CustomRule = { ...rule, id, createdAt: timestamp, lastModified: timestamp, }; this.rules.set(id, newRule); await this.saveRules(); return id; } async updateRule(id: string, updates: Partial<Omit<CustomRule, 'id' | 'createdAt'>>): Promise<boolean> { const existingRule = this.rules.get(id); if (!existingRule) { return false; } const updatedRule: CustomRule = { ...existingRule, ...updates, lastModified: new Date().toISOString(), }; this.rules.set(id, updatedRule); await this.saveRules(); return true; } async removeRule(id: string): Promise<boolean> { const deleted = this.rules.delete(id); if (deleted) { await this.saveRules(); } return deleted; } async enableRule(id: string): Promise<boolean> { return await this.updateRule(id, { enabled: true }); } async disableRule(id: string): Promise<boolean> { return await this.updateRule(id, { enabled: false }); } getRules(): CustomRule[] { return Array.from(this.rules.values()); } getRule(id: string): CustomRule | undefined { return this.rules.get(id); } getStats(): RuleEngineStats { const rules = Array.from(this.rules.values()); const enabled = rules.filter(r => r.enabled); const categoryCounts: Record<string, number> = {}; const severityCounts: Record<string, number> = {}; for (const rule of rules) { categoryCounts[rule.category] = (categoryCounts[rule.category] || 0) + 1; severityCounts[rule.severity] = (severityCounts[rule.severity] || 0) + 1; } return { totalRules: rules.length, enabledRules: enabled.length, disabledRules: rules.length - enabled.length, categoryCounts, severityCounts, }; } async validateRule(rule: Partial<CustomRule>): Promise<{ valid: boolean; errors: string[] }> { const errors: string[] = []; if (!rule.name || rule.name.trim().length === 0) { errors.push('Rule name is required'); } if (!rule.pattern || rule.pattern.trim().length === 0) { errors.push('Rule pattern is required'); } else { try { new RegExp(rule.pattern); } catch { errors.push('Invalid regex pattern'); } // Check for ReDoS patterns if (!this.isPatternSafe(rule.pattern)) { errors.push('Potentially dangerous ReDoS pattern detected - pattern may cause performance issues'); } } if (!rule.messageTemplate || rule.messageTemplate.trim().length === 0) { errors.push('Message template is required'); } if (!['critical', 'high', 'medium', 'low', 'informational'].includes(rule.severity as string)) { errors.push('Invalid severity level'); } if (!['security', 'performance', 'maintainability', 'style'].includes(rule.category as string)) { errors.push('Invalid category'); } return { valid: errors.length === 0, errors, }; } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/NeoTecDigital/mcp_shamash'

If you have feedback or need assistance with the MCP directory API, please join our Discord server