import { DiffProviderPort, SecurityScannerPort, OutputPort, Diff, CodeBlock } from '../ports'; import { SecurityIssue } from '../entities/SecurityIssue'; import * as fs from 'fs'; import * as path from 'path'; import { glob } from 'glob'; export interface SecurityScanOptions { readonly baseRef?: string; readonly headRef?: string; readonly includeStaged?: boolean; readonly includeUnstaged?: boolean; readonly filePatterns?: string[]; readonly excludePatterns?: string[]; readonly outputFormat?: 'console' | 'markdown' | 'json' | 'html' | 'file'; readonly outputPath?: string; readonly severity?: ('low' | 'medium' | 'high' | 'critical')[]; readonly categories?: ('injection' | 'authentication' | 'authorization' | 'cryptography' | 'data_exposure' | 'input_validation' | 'dependency' | 'configuration' | 'logging' | 'other')[]; readonly includeDependencies?: boolean; readonly packageJsonPath?: string; readonly lockFilePath?: string; } export interface ScanSecurityUseCase { execute(options?: SecurityScanOptions): Promise; } export class ScanSecurityUseCaseImpl implements ScanSecurityUseCase { constructor( private readonly diffProvider: DiffProviderPort, private readonly securityScanner: SecurityScannerPort, private readonly outputPort: OutputPort ) {} async execute(options: SecurityScanOptions = {}): Promise { try { let codeBlocks: CodeBlock[] = []; // Get diffs const diffs = await this.diffProvider.getDiffs({ baseRef: options.baseRef, headRef: options.headRef, includeStaged: options.includeStaged, includeUnstaged: options.includeUnstaged, filePatterns: options.filePatterns, excludePatterns: options.excludePatterns, }); // Check if we have meaningful diffs or should use file patterns const hasFilePatterns = options.filePatterns && options.filePatterns.length > 0; const hasMeaningfulDiffs = diffs.length > 0 && diffs.some(diff => diff.newContent && diff.newContent.trim().length > 0 ); if (hasFilePatterns) { // When file patterns are provided, prioritize them for security scanning // This ensures we scan the full file content, not just git diff snippets codeBlocks = await this.readFilesFromPatterns(options.filePatterns, options.excludePatterns); } else if (diffs.length > 0) { // Convert diffs to code blocks codeBlocks = this.convertDiffsToCodeBlocks(diffs); } else { throw new Error('No changes found to scan for security issues'); } // Scan for security issues const securityIssues = await this.scanForSecurityIssues(codeBlocks, options); // Scan for dependency vulnerabilities if requested let dependencyVulnerabilities: SecurityIssue[] = []; if (options.includeDependencies) { dependencyVulnerabilities = await this.scanDependencies(options); } // Combine all security issues const allIssues = [...securityIssues, ...dependencyVulnerabilities]; // Output the results await this.outputPort.displaySecurityIssues(allIssues, { format: options.outputFormat || 'console', outputPath: options.outputPath, }); return allIssues; } catch (error) { await this.outputPort.displayError(`Security scan failed: ${error instanceof Error ? error.message : 'Unknown error'}`); throw error; } } private convertDiffsToCodeBlocks(diffs: Diff[]): CodeBlock[] { const codeBlocks: CodeBlock[] = []; for (const diff of diffs) { if (diff.isBinary) { continue; } // Include both old and new content for comprehensive analysis if (diff.oldContent) { codeBlocks.push({ content: diff.oldContent, language: diff.language || this.getLanguageFromFile(diff.filePath), startLine: 1, endLine: diff.oldContent.split('\n').length, filePath: diff.filePath, }); } if (diff.newContent) { codeBlocks.push({ content: diff.newContent, language: diff.language || this.getLanguageFromFile(diff.filePath), startLine: 1, endLine: diff.newContent.split('\n').length, filePath: diff.filePath, }); } } return codeBlocks; } private async readFilesFromPatterns(filePatterns: string[], excludePatterns: string[] = []): Promise { const codeBlocks: CodeBlock[] = []; for (const pattern of filePatterns) { try { const resolvedPattern = this.resolvePattern(pattern); const files = await this.expandPattern(resolvedPattern); for (const filePath of files) { // Skip if file matches exclude patterns if (this.matchesExcludePatterns(filePath, excludePatterns)) { continue; } // Skip if not a readable file if (!this.isReadableFile(filePath)) { continue; } try { const content = fs.readFileSync(filePath, 'utf-8'); const language = this.getLanguageFromFile(filePath); codeBlocks.push({ content, language, startLine: 1, endLine: content.split('\n').length, filePath: filePath, }); } catch (error) { // Skip files that can't be read continue; } } } catch (error) { // Skip patterns that can't be resolved continue; } } return codeBlocks; } private resolvePattern(pattern: string): string { // Handle different path formats if (path.isAbsolute(pattern)) { return pattern; } // If pattern starts with '/', it's relative to root, not current directory if (pattern.startsWith('/')) { // Remove leading slash and treat as relative to current directory const cleanPattern = pattern.substring(1); return path.resolve(process.cwd(), cleanPattern); } // Regular relative path return path.resolve(process.cwd(), pattern); } private async expandPattern(pattern: string): Promise { try { const stats = fs.statSync(pattern); if (stats.isFile()) { // Single file return [pattern]; } else if (stats.isDirectory()) { // Directory - scan for common code files const extensions = [ '*.js', '*.ts', '*.jsx', '*.tsx', '*.py', '*.java', '*.cs', '*.cpp', '*.c', '*.h', '*.go', '*.rs', '*.php', '*.rb', '*.swift', '*.kt', '*.scala', '*.dart', '*.yaml', '*.yml', '*.json', '*.xml', '*.html', '*.css', '*.scss', '*.less', '*.sql', '*.sh', '*.bash', '*.ps1', '*.dockerfile', '*.md' ]; const files: string[] = []; for (const ext of extensions) { const globPattern = path.join(pattern, '**', ext); const matches = await glob(globPattern, { ignore: ['node_modules/**', '.git/**', 'dist/**', 'build/**'] }); files.push(...matches); } return files; } } catch (error) { // Pattern doesn't exist as file or directory, try as glob pattern try { return await glob(pattern, { ignore: ['node_modules/**', '.git/**', 'dist/**', 'build/**'] }); } catch (globError) { // If glob also fails, return empty array return []; } } return []; } private matchesExcludePatterns(filePath: string, excludePatterns: string[]): boolean { for (const excludePattern of excludePatterns) { try { const resolvedExclude = this.resolvePattern(excludePattern); if (filePath.includes(resolvedExclude)) { return true; } } catch (error) { // Skip invalid exclude patterns continue; } } return false; } private isReadableFile(filePath: string): boolean { try { const stats = fs.statSync(filePath); return stats.isFile() && stats.size > 0 && stats.size < 10 * 1024 * 1024; // Max 10MB } catch (error) { return false; } } private getLanguageFromFile(filePath: string): string { const ext = filePath.split('.').pop()?.toLowerCase(); const languageMap: Record = { 'js': 'javascript', 'ts': 'typescript', 'jsx': 'javascript', 'tsx': 'typescript', 'py': 'python', 'java': 'java', 'cs': 'csharp', 'cpp': 'cpp', 'c': 'c', 'go': 'go', 'rs': 'rust', 'php': 'php', 'rb': 'ruby', 'swift': 'swift', 'kt': 'kotlin', 'scala': 'scala', 'dart': 'dart', 'yaml': 'yaml', 'yml': 'yaml', 'json': 'json', 'xml': 'xml', 'html': 'html', 'css': 'css', 'scss': 'scss', 'less': 'less', 'sql': 'sql', 'sh': 'bash', 'bash': 'bash', 'ps1': 'powershell', 'dockerfile': 'dockerfile', 'md': 'markdown', }; return languageMap[ext || ''] || 'text'; } private async scanForSecurityIssues(codeBlocks: CodeBlock[], options: SecurityScanOptions): Promise { const scanOptions = { severity: options.severity || ['low', 'medium', 'high', 'critical'], categories: options.categories || ['injection', 'authentication', 'authorization', 'cryptography', 'data_exposure', 'input_validation', 'dependency', 'configuration', 'logging', 'other'], includePatterns: options.filePatterns, excludePatterns: options.excludePatterns, }; // Scan for general security issues const generalIssues = await this.securityScanner.scanCodeBlocks(codeBlocks, scanOptions); // Scan for hardcoded secrets const secretIssues = await this.securityScanner.scanForSecrets(codeBlocks); // Scan for insecure patterns const patternIssues = await this.securityScanner.scanForInsecurePatterns(codeBlocks); return [...generalIssues, ...secretIssues, ...patternIssues]; } private async scanDependencies(options: SecurityScanOptions): Promise { if (!options.packageJsonPath) { return []; } try { const vulnerabilities = await this.securityScanner.scanDependencies( options.packageJsonPath, options.lockFilePath ); // Convert dependency vulnerabilities to SecurityIssue format return vulnerabilities.map(vuln => ({ id: `dep-vuln-${vuln.packageName}-${vuln.version}`, severity: vuln.severity, category: 'dependency' as const, title: `Vulnerability in ${vuln.packageName}`, description: vuln.description, filePath: options.packageJsonPath!, cweId: vuln.cve, remediation: vuln.fixedVersion ? `Update ${vuln.packageName} to version ${vuln.fixedVersion} or later` : `Review and update ${vuln.packageName}`, references: vuln.references, metadata: { packageName: vuln.packageName, version: vuln.version, fixedVersion: vuln.fixedVersion, }, })); } catch (error) { // Return empty array if dependency scanning fails return []; } } }