import { Command } from 'commander'; import chalk from 'chalk'; import ora from 'ora'; import axios from 'axios'; import { glob } from 'glob'; import fs from 'fs/promises'; import path from 'path'; import { getApiKey, getApiUrl, getProjectId } from '../utils/config.js'; import { readGitignore, shouldIgnore, isCodeFile } from '../utils/files.js'; interface ScanResult { id: string; file_path: string; issues: Array<{ type: string; severity: 'critical' | 'high' | 'medium' | 'low' | 'info'; message: string; line?: number; }>; } interface ApiResponse { results: ScanResult[]; summary: { total_files: number; total_issues: number; by_severity: Record; }; } export function createScanCommand(): Command { return new Command('scan') .description('Scan code files for security and quality issues') .argument('[path]', 'Directory or file to scan', '.') .option('--json', 'Output results as JSON') .option('--project ', 'Project ID to associate with this scan') .action(async (targetPath: string, options: { json?: boolean; project?: string }) => { const spinner = ora(); try { // Get API credentials const apiKey = getApiKey(); const apiUrl = getApiUrl(); const projectId = options.project || getProjectId(); if (!projectId) { console.warn( chalk.yellow( '⚠️ No project ID specified. Use --project or set a default.' ) ); } // Resolve target path const scanPath = path.resolve(process.cwd(), targetPath); spinner.start(`Scanning ${chalk.cyan(scanPath)}...`); // Read .gitignore patterns const gitignorePatterns = await readGitignore(scanPath); // Find all code files const pattern = path.join(scanPath, '**/*'); const allFiles = await glob(pattern, { nodir: true, dot: false, ignore: ['**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**'], }); // Filter files const codeFiles = allFiles.filter((file) => { const relativePath = path.relative(scanPath, file); return isCodeFile(file) && !shouldIgnore(relativePath, gitignorePatterns); }); if (codeFiles.length === 0) { spinner.warn(chalk.yellow('No code files found to scan.')); return; } spinner.text = `Found ${codeFiles.length} files. Scanning...`; // Scan each file individually const results: ScanResult[] = []; let totalIssues = 0; const severityCounts: Record = {}; for (let i = 0; i < codeFiles.length; i++) { const filePath = codeFiles[i]; const relativePath = path.relative(scanPath, filePath); spinner.text = `Scanning ${i + 1}/${codeFiles.length}: ${relativePath}`; try { const content = await fs.readFile(filePath, 'utf-8'); // Call the API for this file const response = await axios.post( `${apiUrl}/api/v1/audit`, { content, file_path: relativePath, project_id: projectId, }, { headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, timeout: 60000, // 1 minute per file } ); // Aggregate results const vulnerabilities = response.data.vulnerabilities || []; if (vulnerabilities.length > 0) { results.push({ id: response.data.id || relativePath, file_path: relativePath, issues: vulnerabilities.map((v: any) => ({ type: v.type, severity: v.severity, message: v.description || v.title, line: v.line_number, })), }); totalIssues += vulnerabilities.length; vulnerabilities.forEach((v: any) => { severityCounts[v.severity] = (severityCounts[v.severity] || 0) + 1; }); } } catch (fileError) { if (axios.isAxiosError(fileError)) { console.warn(chalk.yellow(`\n⚠️ Skipping ${relativePath}: ${fileError.message}`)); } else { console.warn(chalk.yellow(`\n⚠️ Error reading ${relativePath}`)); } } } spinner.succeed(chalk.green('✅ Scan completed!')); // Build aggregated response const aggregatedResponse: ApiResponse = { results, summary: { total_files: codeFiles.length, total_issues: totalIssues, by_severity: severityCounts, }, }; // Output results if (options.json) { console.log(JSON.stringify(aggregatedResponse, null, 2)); } else { printPrettyResults(aggregatedResponse); } } catch (error) { spinner.fail(chalk.red('❌ Scan failed')); if (axios.isAxiosError(error)) { if (error.response) { console.error(chalk.red('API Error:'), error.response.data); } else if (error.request) { console.error( chalk.red('Network Error:'), 'Could not reach the API. Is the server running?' ); } else { console.error(chalk.red('Error:'), error.message); } } else { console.error( chalk.red('Error:'), error instanceof Error ? error.message : 'Unknown error' ); } process.exit(1); } }); } function printPrettyResults(data: ApiResponse): void { const { results, summary } = data; console.log('\n' + chalk.bold('📊 Scan Summary')); console.log(chalk.dim('─'.repeat(60))); console.log(`Total Files Scanned: ${chalk.cyan(summary.total_files)}`); console.log(`Total Issues Found: ${chalk.yellow(summary.total_issues)}`); if (summary.by_severity) { console.log('\nIssues by Severity:'); Object.entries(summary.by_severity).forEach(([severity, count]) => { const color = getSeverityColor(severity as any); console.log(` ${color(`${severity}:`)} ${count}`); }); } if (results && results.length > 0) { console.log('\n' + chalk.bold('🔍 Detailed Results')); console.log(chalk.dim('─'.repeat(60))); results.forEach((result) => { if (result.issues && result.issues.length > 0) { console.log(`\n${chalk.bold(result.file_path)}`); result.issues.forEach((issue) => { const severityColor = getSeverityColor(issue.severity); const lineInfo = issue.line ? chalk.dim(`:${issue.line}`) : ''; console.log( ` ${severityColor(`[${issue.severity.toUpperCase()}]`)} ${issue.type}${lineInfo}` ); console.log(` ${chalk.dim(issue.message)}`); }); } }); } console.log('\n' + chalk.dim('─'.repeat(60))); } function getSeverityColor(severity: string): (str: string) => string { switch (severity.toLowerCase()) { case 'critical': return chalk.red.bold; case 'high': return chalk.red; case 'medium': return chalk.yellow; case 'low': return chalk.blue; case 'info': return chalk.gray; default: return chalk.white; } }