feat: Vibe-Coded SaaS Security Scanner v1.0.0 - automated security scanner for vibe-coded AI-generated SaaS apps
This commit is contained in:
+237
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user