import type { Finding, ScanResult } from './scanner.js'; export type Format = 'text' | 'json' | 'sarif' | 'markdown'; export interface ReporterOptions { format: Format; output?: string; showSnippet?: boolean; minSeverity?: string; } const SEVERITY_ORDER = { critical: 0, high: 1, medium: 2, low: 3 }; const SEVERITY_ICONS = { critical: 'šŸ”“', high: '🟠', medium: '🟔', low: '🟢' }; const SEVERITY_COLORS = { critical: '\x1b[31m', high: '\x1b[33m', medium: '\x1b[36m', low: '\x1b[32m' }; const RESET = '\x1b[0m'; function severityRank(s: string): number { return SEVERITY_ORDER[s as keyof typeof SEVERITY_ORDER] ?? 99; } function filterFindings(findings: Finding[], minSeverity?: string): Finding[] { if (!minSeverity) return findings; const minRank = severityRank(minSeverity); return findings.filter((f) => severityRank(f.severity) <= minRank); } function sortFindings(findings: Finding[]): Finding[] { return [...findings].sort((a, b) => { const sevDiff = severityRank(a.severity) - severityRank(b.severity); if (sevDiff !== 0) return sevDiff; if (a.file !== b.file) return a.file.localeCompare(b.file); return a.line - b.line; }); } function formatText(result: ScanResult, opts: ReporterOptions): string { let out = ''; const findings = sortFindings(filterFindings(result.findings, opts.minSeverity)); out += '\n'; out += '╔══════════════════════════════════════════════════════════════╗\n'; out += 'ā•‘ Vibe-Coded SaaS Security Scanner - Results ā•‘\n'; out += 'ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•\n'; out += '\n'; out += `Files scanned: ${result.filesScanned}\n`; out += `Files skipped: ${result.filesSkipped}\n`; out += `Rules run: ${result.rulesRun}\n`; out += `Duration: ${result.durationMs}ms\n`; out += `\n`; if (findings.length === 0) { out += 'āœ… No security issues found.\n'; return out; } // Summary by severity const counts = { critical: 0, high: 0, medium: 0, low: 0 }; for (const f of findings) { counts[f.severity] = (counts[f.severity] || 0) + 1; } out += 'Summary:\n'; for (const [sev, count] of Object.entries(counts)) { if (count > 0) { const icon = SEVERITY_ICONS[sev as keyof typeof SEVERITY_ICONS]; const color = SEVERITY_COLORS[sev as keyof typeof SEVERITY_COLORS]; out += ` ${icon} ${color}${sev.toUpperCase()}${RESET}: ${count}\n`; } } out += `\n`; out += `Found ${findings.length} issue(s):\n`; out += `\n`; let currentFile = ''; for (const f of findings) { if (f.file !== currentFile) { currentFile = f.file; out += `\nšŸ“ ${f.file}\n`; out += '─'.repeat(60) + '\n'; } const color = SEVERITY_COLORS[f.severity] || ''; const icon = SEVERITY_ICONS[f.severity] || '⚪'; out += ` ${icon} ${color}[${f.severity.toUpperCase()}]${RESET} ${f.ruleName} (${f.ruleId})\n`; out += ` Line ${f.line}, Col ${f.column}\n`; if (opts.showSnippet !== false) { out += ` ${f.snippet}\n`; } out += ` ${f.description}\n`; } out += '\n'; out += '────────────────────────────────────────────────────────────\n'; out += 'šŸ’” Tip: Fix CRITICAL issues immediately. HIGH issues within 24h.\n'; out += ' Run with --format json for CI integration.\n'; return out; } function formatJson(result: ScanResult, opts: ReporterOptions): string { const findings = sortFindings(filterFindings(result.findings, opts.minSeverity)); const payload = { version: '1.0.0', scanner: 'vibe-coded-saas-security-scanner', summary: { filesScanned: result.filesScanned, filesSkipped: result.filesSkipped, rulesRun: result.rulesRun, durationMs: result.durationMs, totalFindings: findings.length, severityCounts: findings.reduce((acc, f) => { acc[f.severity] = (acc[f.severity] || 0) + 1; return acc; }, {} as Record), }, findings: findings.map((f) => ({ ruleId: f.ruleId, ruleName: f.ruleName, severity: f.severity, category: f.category, description: f.description, location: { file: f.file, line: f.line, column: f.column, }, snippet: f.snippet, match: f.match, })), }; return JSON.stringify(payload, null, 2); } function formatMarkdown(result: ScanResult, opts: ReporterOptions): string { const findings = sortFindings(filterFindings(result.findings, opts.minSeverity)); let out = '# Vibe-Coded SaaS Security Scanner Report\n\n'; out += `| Metric | Value |\n`; out += `|--------|-------|\n`; out += `| Files Scanned | ${result.filesScanned} |\n`; out += `| Files Skipped | ${result.filesSkipped} |\n`; out += `| Rules Run | ${result.rulesRun} |\n`; out += `| Duration | ${result.durationMs}ms |\n`; out += `| Total Findings | ${findings.length} |\n\n`; if (findings.length === 0) { out += 'āœ… No security issues found.\n'; return out; } const counts = { critical: 0, high: 0, medium: 0, low: 0 }; for (const f of findings) { counts[f.severity] = (counts[f.severity] || 0) + 1; } out += '## Severity Summary\n\n'; for (const [sev, count] of Object.entries(counts)) { if (count > 0) out += `- **${sev.toUpperCase()}**: ${count}\n`; } out += '\n'; out += '## Findings\n\n'; let currentFile = ''; for (const f of findings) { if (f.file !== currentFile) { currentFile = f.file; out += `### ${f.file}\n\n`; } out += `#### ${f.ruleName} (${f.ruleId})\n\n`; out += `- **Severity**: ${f.severity.toUpperCase()}\n`; out += `- **Category**: ${f.category}\n`; out += `- **Location**: Line ${f.line}, Column ${f.column}\n`; out += `- **Description**: ${f.description}\n`; if (opts.showSnippet !== false) { out += `- **Snippet**: \`${f.snippet}\`\n`; } out += '\n'; } return out; } function formatSarif(result: ScanResult, opts: ReporterOptions): string { const findings = sortFindings(filterFindings(result.findings, opts.minSeverity)); const sarif = { $schema: 'https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json', version: '2.1.0', runs: [ { tool: { driver: { name: 'vibe-coded-saas-security-scanner', version: '1.0.0', informationUri: 'https://bunbunlabs.com', rules: findings.map((f) => ({ id: f.ruleId, name: f.ruleName, shortDescription: { text: f.description }, defaultConfiguration: { level: f.severity }, })), }, }, results: findings.map((f) => ({ ruleId: f.ruleId, level: f.severity === 'critical' || f.severity === 'high' ? 'error' : f.severity === 'medium' ? 'warning' : 'note', message: { text: f.description }, locations: [ { physicalLocation: { artifactLocation: { uri: f.file }, region: { startLine: f.line, startColumn: f.column, }, }, }, ], properties: { category: f.category, snippet: f.snippet, }, })), }, ], }; return JSON.stringify(sarif, null, 2); } export function report(result: ScanResult, opts: ReporterOptions): string { switch (opts.format) { case 'json': return formatJson(result, opts); case 'sarif': return formatSarif(result, opts); case 'markdown': return formatMarkdown(result, opts); case 'text': default: return formatText(result, opts); } }