UNPKG

ctrlshiftleft

Version:

AI-powered toolkit for embedding QA and security testing into development workflows

675 lines (580 loc) 23.3 kB
#!/usr/bin/env node /** * Ctrl+Shift+Left Enhanced Security Analyzer * A direct command-line tool for analyzing security risks in code with AI capabilities */ const fs = require('fs'); const path = require('path'); const { execSync } = require('child_process'); // Try to import the AI security analyzer let aiAnalyzer; try { aiAnalyzer = require('../src/ai-security-analyzer'); console.log('✅ AI-enhanced security analysis module loaded successfully.'); } catch (error) { console.log('⚠️ AI security analyzer not available, using pattern-based analysis only.'); console.log(` ${error.message}`); aiAnalyzer = null; } // Parse output path from command line arguments function parseOutputPath() { // Look for --output=<path> in the command line arguments const outputArg = process.argv.find(arg => arg.startsWith('--output=')); if (outputArg) { const outputPath = outputArg.split('=')[1]; // Check if the path is absolute or relative if (path.isAbsolute(outputPath)) { return outputPath; } else { // Convert relative path to absolute return path.resolve(process.cwd(), outputPath); } } return null; } // Configuration let TARGET_FILE = process.argv[2] || './demo/src/components/PaymentForm.tsx'; // Generate default output paths relative to current working directory const DEFAULT_OUTPUT_FILE = path.resolve(process.cwd(), 'security-report.md'); const DEFAULT_REPORTS_DIR = path.resolve(process.cwd(), 'reports/security'); // Get output path from command line or environment variables or use defaults let OUTPUT_PATH = parseOutputPath(); let OUTPUT_FILE = OUTPUT_PATH || process.env.CTRLSHIFTLEFT_OUTPUT_PATH || DEFAULT_OUTPUT_FILE; // Ensure reports directory is relative to the specified output or working directory let REPORTS_DIR = OUTPUT_PATH ? path.dirname(OUTPUT_PATH) : process.env.CTRLSHIFTLEFT_REPORTS_DIR || DEFAULT_REPORTS_DIR; const AI_MODE = process.argv.includes('--ai') || process.env.CTRLSHIFTLEFT_AI_ANALYSIS === 'true'; // Parse options from command-line arguments let USE_AI = AI_MODE && aiAnalyzer !== null; const API_KEY = process.env.OPENAI_API_KEY || null; // Ensure all required directories exist function ensureDirectoriesExist() { // Ensure output directory for the main report exists const outputDir = path.dirname(OUTPUT_FILE); if (!fs.existsSync(outputDir)) { console.log(`Creating output directory: ${outputDir}`); fs.mkdirSync(outputDir, { recursive: true }); } // Ensure security-reports directory exists if (!fs.existsSync(REPORTS_DIR)) { console.log(`Creating reports directory: ${REPORTS_DIR}`); fs.mkdirSync(REPORTS_DIR, { recursive: true }); } // Try to load paths configuration from .ctrlshiftleft directory let customPaths = []; try { const ctrlshiftleftDir = path.resolve(process.cwd(), '.ctrlshiftleft'); const pathsFile = path.join(ctrlshiftleftDir, 'paths.js'); if (fs.existsSync(pathsFile)) { const pathsConfig = require(pathsFile); if (pathsConfig.OUTPUT_DIR) { customPaths.push(pathsConfig.OUTPUT_DIR); } } } catch (error) { console.log(`Note: No custom paths loaded - ${error.message}`); } // Create other potential directories (with project-relative paths) const dirs = [ path.resolve(process.cwd(), 'reports/security'), path.resolve(process.cwd(), 'security-reports'), path.resolve(process.cwd(), 'tests'), ...customPaths ]; dirs.forEach(dir => { if (!fs.existsSync(dir)) { console.log(`Creating directory: ${dir}`); fs.mkdirSync(dir, { recursive: true }); } }); } // Load modules using require for backward compatibility let reactPatterns = []; let apiPatterns = []; let configModule = null; let config = { security: { customPatterns: [], disabledPatterns: [], frameworks: { react: { enabled: true }, nextjs: { enabled: true }, express: { enabled: true } } } }; // Try to load the enhanced modules - falls back gracefully if not available try { // Load the React security patterns const reactModule = require('../src/utils/reactSecurityPatterns'); if (reactModule && reactModule.getAllReactPatterns) { reactPatterns = reactModule.getAllReactPatterns(); console.log(`✅ Loaded ${reactPatterns.length} React-specific security patterns`); } } catch (error) { console.log('ℹ️ React security patterns not available, using core patterns only'); } try { // Load the API route analyzer patterns const apiModule = require('../src/utils/apiRouteAnalyzer'); if (apiModule && apiModule.getAllApiPatterns) { apiPatterns = apiModule.getAllApiPatterns(); console.log(`✅ Loaded ${apiPatterns.length} API route security patterns`); } } catch (error) { console.log('ℹ️ API route analyzer not available, using core patterns only'); } try { // Load the configuration module configModule = require('../src/utils/configLoader'); if (configModule && configModule.loadConfig) { config = configModule.loadConfig(); console.log('✅ Loaded custom security configuration'); // Create sample config if it doesn't exist if (configModule.createSampleConfig) { configModule.createSampleConfig(); } } } catch (error) { console.log('ℹ️ Custom configuration not available, using default settings'); } // Core security patterns to scan for const CORE_SECURITY_PATTERNS = [ { id: 'password-plaintext', pattern: /password/i, severity: 'HIGH', title: 'Unprotected Password Field', description: 'Password fields should be properly secured with encryption or hashing', remediation: 'Ensure passwords are never stored in plaintext and are properly hashed before storage' }, { id: 'xss-innerhtml', pattern: /innerHTML|dangerouslySetInnerHTML/, severity: 'CRITICAL', title: 'Potential XSS Vulnerability', description: 'Using innerHTML or dangerouslySetInnerHTML can lead to cross-site scripting attacks', remediation: 'Use safer alternatives like textContent or sanitize input before using' }, { id: 'storage-sensitive', pattern: /localStorage|sessionStorage/, severity: 'MEDIUM', title: 'Client-side Storage of Sensitive Data', description: 'Storing sensitive information in localStorage or sessionStorage is insecure', remediation: 'Use secure storage mechanisms or encrypted tokens for sensitive data' }, { id: 'network-request', pattern: /fetch\s*\(/, severity: 'INFO', title: 'Network Request', description: 'Network requests should be properly secured and validated', remediation: 'Ensure proper CORS settings and validate all input and output' }, { id: 'eval-usage', pattern: /eval\s*\(/, severity: 'CRITICAL', title: 'Use of eval()', description: 'Using eval() can execute arbitrary code and lead to injection attacks', remediation: 'Avoid using eval() completely. Use safer alternatives like JSON.parse() or Function constructors if absolutely necessary' }, { pattern: /document\.write/, severity: 'HIGH', title: 'Use of document.write', description: 'document.write can lead to XSS vulnerabilities and is considered bad practice', remediation: 'Use DOM manipulation methods like createElement and appendChild instead' }, { pattern: /(?:\.|\[(["']))(cookie)\1?\]/, severity: 'MEDIUM', title: 'Cookie Manipulation', description: 'Insecure cookie manipulation can lead to session hijacking', remediation: 'Use secure and httpOnly flags for sensitive cookies' }, { pattern: /jwt/i, severity: 'INFO', title: 'JWT Usage', description: 'JWT tokens should be properly validated and handled securely', remediation: 'Ensure proper validation of JWT tokens and use secure storage' } ]; // Get combined security patterns based on file type and user config function getSecurityPatterns(filePath) { // Start with core patterns let patterns = [...CORE_SECURITY_PATTERNS]; // Determine file type for specialized patterns const fileExt = path.extname(filePath).toLowerCase(); const isReactFile = /\.(jsx|tsx)$/.test(fileExt); const isApiFile = /(\/api\/|\/routes\/|\/controllers\/)/.test(filePath) || /\.(api|controller|route)\.(js|ts)$/.test(filePath); // Add React patterns if it's a React file if (isReactFile && config.security.frameworks.react.enabled && reactPatterns.length > 0) { console.log('📋 Adding React-specific security patterns'); patterns = [...patterns, ...reactPatterns]; } // Add API patterns if it's an API file if (isApiFile && apiPatterns.length > 0) { // Determine which framework patterns to include const frameworks = []; if (config.security.frameworks.nextjs.enabled) { frameworks.push('nextjs'); } if (config.security.frameworks.express.enabled) { frameworks.push('express'); } frameworks.push('node'); // Always include generic Node.js patterns console.log(`📋 Adding API security patterns for frameworks: ${frameworks.join(', ')}`); patterns = [...patterns, ...apiPatterns.filter(p => !p.framework || p.framework.some(f => frameworks.includes(f)) )]; } // Add custom patterns from config if (config.security.customPatterns && config.security.customPatterns.length > 0) { console.log(`📋 Adding ${config.security.customPatterns.length} custom security patterns`); patterns = [...patterns, ...config.security.customPatterns]; } // Filter out disabled patterns if (config.security.disabledPatterns && config.security.disabledPatterns.length > 0) { patterns = patterns.filter(pattern => !pattern.id || !config.security.disabledPatterns.includes(pattern.id) ); console.log(`🔍 Filtered out ${config.security.disabledPatterns.length} disabled patterns`); } return patterns; } // Analyze a file for security vulnerabilities function analyzeFile(filePath) { // Check if file exists if (!fs.existsSync(filePath)) { console.error(`File not found: ${filePath}`); return []; } console.log(`Analyzing file: ${filePath}`); // Get appropriate security patterns for this file const securityPatterns = getSecurityPatterns(filePath); // Read file content const content = fs.readFileSync(filePath, 'utf-8'); const issues = []; // Check for security issues securityPatterns.forEach(pattern => { const matches = content.match(pattern.pattern); if (matches) { issues.push({ pattern: pattern.pattern, severity: pattern.severity, title: pattern.title, description: pattern.description, remediation: pattern.remediation, category: pattern.category || 'general', matches: matches.length }); } }); return issues; } // Generate a markdown report from pattern-based analysis function generateReport(filePath, issues) { const fileName = path.basename(filePath); const severityGroups = { CRITICAL: [], HIGH: [], MEDIUM: [], LOW: [], INFO: [] }; // Group issues by category const categoryGroups = {}; // Sort issues into severity and category groups issues.forEach(issue => { // Add to severity group severityGroups[issue.severity].push(issue); // Add to category group const category = issue.category || 'general'; if (!categoryGroups[category]) { categoryGroups[category] = []; } categoryGroups[category].push(issue); }); // Create report header let report = '# Security Analysis for ' + path.basename(filePath) + '\n\n'; report += `*Generated by Ctrl+Shift+Left v1.4.0 on ${new Date().toLocaleString()}*\n\n`; // Summary report += '## Summary\n\n'; const criticalCount = severityGroups.CRITICAL.length; const highCount = severityGroups.HIGH.length; const mediumCount = severityGroups.MEDIUM.length; const lowCount = severityGroups.LOW.length; const infoCount = severityGroups.INFO.length; report += `- 🚨 Critical: ${criticalCount}\n`; report += `- ⚠️ High: ${highCount}\n`; report += `- ⚠️ Medium: ${mediumCount}\n`; report += `- ℹ️ Low: ${lowCount}\n`; report += `- ℹ️ Info: ${infoCount}\n\n`; // Category summary if multiple categories exist if (Object.keys(categoryGroups).length > 1) { report += '### Categories\n\n'; Object.keys(categoryGroups).sort().forEach(category => { const categoryIssues = categoryGroups[category]; const criticalInCategory = categoryIssues.filter(i => i.severity === 'CRITICAL').length; // Determine category icon based on highest severity let categoryIcon = '📋'; if (criticalInCategory > 0) { categoryIcon = '🚨'; } else if (categoryIssues.some(i => i.severity === 'HIGH')) { categoryIcon = '⚠️'; } report += `${categoryIcon} **${category}**: ${categoryIssues.length} issues\n`; }); report += '\n'; } // Detailed issues by severity report += '## Issues by Severity\n\n'; ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'].forEach(severity => { if (severityGroups[severity].length > 0) { const severityIcon = severity === 'CRITICAL' ? '🚨' : severity === 'HIGH' ? '⚠️' : severity === 'MEDIUM' ? '⚠️' : '📋'; report += `### ${severityIcon} ${severity} Severity\n\n`; severityGroups[severity].forEach(issue => { // Format pattern as more readable regex const patternStr = issue.pattern.toString(); report += `#### ${issue.title}\n\n`; if (issue.matches && issue.matches > 1) { report += `- **Found**: ${issue.matches} occurrences\n`; } report += `- **Pattern**: \`${patternStr}\`\n`; report += `- **Description**: ${issue.description}\n`; report += `- **Remediation**: ${issue.remediation}\n`; if (issue.category && issue.category !== 'general') { report += `- **Category**: ${issue.category}\n`; } report += '\n'; }); } }); // Detailed issues by category if multiple categories exist if (Object.keys(categoryGroups).length > 1) { report += '## Issues by Category\n\n'; Object.keys(categoryGroups).sort().forEach(category => { if (category === 'general') return; // Skip general category as it's already covered report += `### ${category}\n\n`; // Group by severity within category const categoryBySeverity = { CRITICAL: [], HIGH: [], MEDIUM: [], LOW: [], INFO: [] }; categoryGroups[category].forEach(issue => { categoryBySeverity[issue.severity].push(issue); }); ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'].forEach(severity => { categoryBySeverity[severity].forEach(issue => { report += `#### ${issue.title} (${severity})\n\n`; report += `- **Description**: ${issue.description}\n`; report += `- **Remediation**: ${issue.remediation}\n\n`; }); }); }); } // Add test generation recommendation report += '## Next Steps\n\n'; report += '1. Review and fix the identified security issues\n'; report += '2. Generate tests to verify fixes using `npx ctrlshiftleft gen <file> --output tests`\n'; report += '3. Run the generated tests to validate your changes\n'; if (criticalCount > 0 || highCount > 0) { report += '4. Run security scan again to verify all critical and high issues are resolved\n'; } return report; } // Combine pattern-based and AI analysis async function enhancedAnalysis(filePath) { // Pattern-based analysis const patternIssues = analyzeFile(filePath); console.log(`Pattern-based analysis found ${patternIssues.length} potential issues.`); // AI-powered analysis (if available and enabled) if (USE_AI) { console.log('🧠 Using AI-enhanced security analysis...'); try { // Set the API key if provided if (API_KEY) { aiAnalyzer.setApiKey(API_KEY); } // Prepare output paths const fileName = path.basename(filePath, path.extname(filePath)); const aiReportPath = path.join(REPORTS_DIR, `${fileName}-ai.md`); // Run AI analysis const aiResult = await aiAnalyzer.analyzeWithAI(filePath, { format: 'markdown', output: aiReportPath }); if (aiResult.error) { console.warn(`⚠️ AI analysis encountered an error: ${aiResult.error}`); console.log('Using pattern-based analysis results only.'); return generateReport(filePath, patternIssues); } // Successfully generated AI report console.log(`🎉 AI-enhanced analysis complete. Report written to: ${aiReportPath}`); // Return the AI-generated markdown directly return aiResult.markdown; } catch (error) { console.error(`❌ Error during AI analysis: ${error.message}`); console.log('Falling back to pattern-based analysis...'); return generateReport(filePath, patternIssues); } } else { // Use pattern-based analysis only return generateReport(filePath, patternIssues); } } /** * Maximum number of retries for network operations */ const MAX_RETRIES = 3; /** * Retry a function with exponential backoff * @param {Function} fn - Function to retry * @param {number} maxRetries - Maximum number of retries * @param {number} initialDelay - Initial delay in ms * @returns {Promise<any>} - Result of the function */ async function retryWithBackoff(fn, maxRetries = MAX_RETRIES, initialDelay = 500) { let retries = 0; let lastError; while (retries <= maxRetries) { try { return await fn(); } catch (error) { lastError = error; if (retries === maxRetries) break; // Calculate delay with exponential backoff const delay = initialDelay * Math.pow(2, retries); console.log(`Operation failed, retrying in ${delay}ms... (${retries + 1}/${maxRetries})`); await new Promise(resolve => setTimeout(resolve, delay)); retries++; } } throw lastError; } // Main function async function main() { // Extract command line arguments const args = process.argv.slice(2); // Parse arguments let targetFilePath = TARGET_FILE; let outputFilePath = OUTPUT_FILE; let useAiMode = USE_AI; let configPath = null; // Extract target file (first non-option argument) const nonOptionArgs = args.filter(arg => !arg.startsWith('--')); if (nonOptionArgs.length > 0) { targetFilePath = nonOptionArgs[0]; } // Parse option arguments args.forEach(arg => { if (arg.startsWith('--output=')) { outputFilePath = arg.substring('--output='.length); } else if (arg === '--ai') { useAiMode = true; } else if (arg.startsWith('--config=')) { configPath = arg.substring('--config='.length); } else if (arg === '--no-ai') { useAiMode = false; } }); // If config module is available, use it to determine output format and directory if (configModule && configModule.loadConfig) { try { // Load config from specified path or default location const customConfig = configPath ? configModule.loadConfig(path.dirname(configPath)) : configModule.loadConfig(); // Apply output configuration if not explicitly overridden by command line if (customConfig.security?.output && args.every(arg => !arg.startsWith('--output='))) { const format = customConfig.security.output.format || 'markdown'; const directory = customConfig.security.output.directory || './security-reports'; // Create output directory if it doesn't exist if (!fs.existsSync(directory)) { fs.mkdirSync(directory, { recursive: true }); } // Generate output filename const fileBaseName = path.basename(targetFilePath, path.extname(targetFilePath)); outputFilePath = path.join(directory, `${fileBaseName}-security.${format === 'markdown' ? 'md' : format}`); } } catch (configError) { console.warn(`Warning: Could not load configuration: ${configError.message}`); console.warn('Using default settings instead.'); } } console.log('Ctrl+Shift+Left Security Analyzer'); console.log('================================='); console.log(`Analysis mode: ${useAiMode ? '🧠 AI-enhanced' : '🔍 Pattern-based'}`); console.log(`Target: ${targetFilePath}`); console.log(`Output: ${outputFilePath}`); // Ensure output directories exist try { const outputDir = path.dirname(outputFilePath); if (!fs.existsSync(outputDir)) { console.log(`Creating directory: ${outputDir}`); fs.mkdirSync(outputDir, { recursive: true }); } } catch (dirError) { console.error(`Error creating output directory: ${dirError.message}`); console.log('Falling back to default output location...'); outputFilePath = path.join(process.cwd(), 'security-report.md'); } // Update global variables to use the new values TARGET_FILE = targetFilePath; OUTPUT_FILE = outputFilePath; USE_AI = useAiMode; try { // Generate report with retry logic for network operations const report = await retryWithBackoff(async () => { return await enhancedAnalysis(TARGET_FILE); }); // Write report to file try { fs.writeFileSync(OUTPUT_FILE, report); } catch (writeError) { console.error(`Error writing report: ${writeError.message}`); const fallbackPath = path.join(process.cwd(), 'security-report.md'); console.log(`Attempting to write to fallback location: ${fallbackPath}`); fs.writeFileSync(fallbackPath, report); OUTPUT_FILE = fallbackPath; } // Generate relative path for displaying to user const relativeOutputPath = path.relative(process.cwd(), OUTPUT_FILE); // Print summary with paths console.log(`Analysis complete. Report written to: ${relativeOutputPath}`); console.log(`Absolute path: ${OUTPUT_FILE}`); // Only try to open the report if not running in CI/CD environment if (!process.env.CI && !process.env.GITHUB_ACTIONS && process.stdout.isTTY) { try { // OS-specific command to open file const openCommand = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start "" ' : 'xdg-open'; execSync(`${openCommand} "${OUTPUT_FILE}"`); } catch (openError) { console.log('Could not automatically open the report.'); } } } catch (error) { console.error(`Error during analysis: ${error.message}`); process.exit(1); } } // Run the analyzer main().catch(error => { console.error(`Unhandled error: ${error.message}`); process.exit(1); });