238 lines
8.0 KiB
TypeScript
238 lines
8.0 KiB
TypeScript
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<string, number>),
|
|
},
|
|
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);
|
|
}
|
|
}
|