feat: Vibe-Coded SaaS Security Scanner v1.0.0 - automated security scanner for vibe-coded AI-generated SaaS apps

This commit is contained in:
Bun Bun
2026-06-18 18:27:39 +00:00
commit fb175822b7
13 changed files with 3415 additions and 0 deletions
+237
View File
@@ -0,0 +1,237 @@
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);
}
}