feat: Vibe-Coded SaaS Security Scanner v1.0.0 - automated security scanner for vibe-coded AI-generated SaaS apps
This commit is contained in:
+12
@@ -0,0 +1,12 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.log
|
||||||
|
.verdict
|
||||||
|
.venv/
|
||||||
|
.vite/
|
||||||
|
.DS_Store
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
# Vibe-Coded SaaS Security Scanner
|
||||||
|
|
||||||
|
Automated security scanner for vibe-coded / AI-generated SaaS apps. Detects exposed API keys, missing input validation, and insecure client-side secrets.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g vibe-coded-saas-security-scanner
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Scan current directory
|
||||||
|
vibe-scan .
|
||||||
|
|
||||||
|
# Scan specific paths with JSON output
|
||||||
|
vibe-scan --format json --output report.json ./src ./lib
|
||||||
|
|
||||||
|
# Only show critical and high severity findings
|
||||||
|
vibe-scan --min-severity high ./src
|
||||||
|
|
||||||
|
# Only scan for secrets
|
||||||
|
vibe-scan --categories secret ./src
|
||||||
|
```
|
||||||
|
|
||||||
|
## Output Formats
|
||||||
|
|
||||||
|
- `text` (default) — human-readable colored terminal output
|
||||||
|
- `json` — structured JSON for CI integration
|
||||||
|
- `sarif` — SARIF v2.1.0 for GitHub/CodeQL integration
|
||||||
|
- `markdown` — Markdown report for PR comments
|
||||||
|
|
||||||
|
## Exit Codes
|
||||||
|
|
||||||
|
- `0` — No critical findings
|
||||||
|
- `1` — Scanner error
|
||||||
|
- `2` — At least one critical finding detected
|
||||||
|
|
||||||
|
## Categories
|
||||||
|
|
||||||
|
- **secret** — Exposed API keys, tokens, passwords, private keys
|
||||||
|
- **injection** — SQL injection, NoSQL injection, command injection, path traversal
|
||||||
|
- **client-side** — DOM XSS, dangerous eval, dangerouslySetInnerHTML
|
||||||
|
- **validation** — Missing input validation, mass assignment, insecure uploads
|
||||||
|
- **config** — Missing CORS, insecure cookies, debug mode, HTTP instead of HTTPS
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
The scanner includes 25+ detection rules covering:
|
||||||
|
|
||||||
|
- Stripe, AWS, OpenAI, Twilio, SendGrid, GitHub, Slack tokens
|
||||||
|
- Database connection strings
|
||||||
|
- JWT secrets and session keys
|
||||||
|
- Private keys
|
||||||
|
- SQL / NoSQL / Command injection patterns
|
||||||
|
- Path traversal
|
||||||
|
- XSS and dangerous DOM operations
|
||||||
|
- Missing CORS, cookie, rate limiting configurations
|
||||||
|
- Insecure file uploads
|
||||||
|
- Debug mode in production
|
||||||
|
|
||||||
|
## CI Integration
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Security Scan
|
||||||
|
run: |
|
||||||
|
npx vibe-coded-saas-security-scanner --format sarif --output security.sarif .
|
||||||
|
- name: Upload SARIF
|
||||||
|
uses: github/codeql-action/upload-sarif@v3
|
||||||
|
with:
|
||||||
|
sarif_file: security.sarif
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
Generated
+1887
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "vibe-coded-saas-security-scanner",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Automated security scanner for vibe-coded / AI-generated SaaS apps. Detects exposed API keys, missing input validation, and insecure client-side secrets.",
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"bin": {
|
||||||
|
"vibe-scan": "dist/cli.js"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"test": "vitest run",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"keywords": ["security", "scanner", "saas", "ai-generated", "vibe-coding"],
|
||||||
|
"author": "BunBun Labs",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "^5.5.0",
|
||||||
|
"vitest": "^1.6.0",
|
||||||
|
"@types/node": "^20.14.0"
|
||||||
|
},
|
||||||
|
"dependencies": {}
|
||||||
|
}
|
||||||
+186
@@ -0,0 +1,186 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { scan } from './scanner.js';
|
||||||
|
import { report } from './reporter.js';
|
||||||
|
import { writeFile } from 'node:fs/promises';
|
||||||
|
import { resolve } from 'node:path';
|
||||||
|
|
||||||
|
interface CliArgs {
|
||||||
|
paths: string[];
|
||||||
|
format: 'text' | 'json' | 'sarif' | 'markdown';
|
||||||
|
output?: string;
|
||||||
|
exclude: string[];
|
||||||
|
rules?: string[];
|
||||||
|
categories?: string[];
|
||||||
|
severities?: string[];
|
||||||
|
maxFileSize: number;
|
||||||
|
noGitignore: boolean;
|
||||||
|
showSnippet: boolean;
|
||||||
|
minSeverity?: string;
|
||||||
|
help: boolean;
|
||||||
|
version: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseArgs(): CliArgs {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const parsed: CliArgs = {
|
||||||
|
paths: [],
|
||||||
|
format: 'text',
|
||||||
|
exclude: [],
|
||||||
|
maxFileSize: 1024 * 1024,
|
||||||
|
noGitignore: false,
|
||||||
|
showSnippet: true,
|
||||||
|
help: false,
|
||||||
|
version: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const arg = args[i];
|
||||||
|
switch (arg) {
|
||||||
|
case '-h':
|
||||||
|
case '--help':
|
||||||
|
parsed.help = true;
|
||||||
|
break;
|
||||||
|
case '-v':
|
||||||
|
case '--version':
|
||||||
|
parsed.version = true;
|
||||||
|
break;
|
||||||
|
case '-f':
|
||||||
|
case '--format':
|
||||||
|
parsed.format = (args[++i] as CliArgs['format']) || 'text';
|
||||||
|
break;
|
||||||
|
case '-o':
|
||||||
|
case '--output':
|
||||||
|
parsed.output = args[++i];
|
||||||
|
break;
|
||||||
|
case '-e':
|
||||||
|
case '--exclude':
|
||||||
|
parsed.exclude.push(args[++i]);
|
||||||
|
break;
|
||||||
|
case '-r':
|
||||||
|
case '--rules':
|
||||||
|
parsed.rules = (args[++i] || '').split(',').filter(Boolean);
|
||||||
|
break;
|
||||||
|
case '-c':
|
||||||
|
case '--categories':
|
||||||
|
parsed.categories = (args[++i] || '').split(',').filter(Boolean);
|
||||||
|
break;
|
||||||
|
case '-s':
|
||||||
|
case '--severities':
|
||||||
|
parsed.severities = (args[++i] || '').split(',').filter(Boolean);
|
||||||
|
break;
|
||||||
|
case '--max-file-size':
|
||||||
|
parsed.maxFileSize = parseInt(args[++i], 10) || 1024 * 1024;
|
||||||
|
break;
|
||||||
|
case '--no-gitignore':
|
||||||
|
parsed.noGitignore = true;
|
||||||
|
break;
|
||||||
|
case '--no-snippet':
|
||||||
|
parsed.showSnippet = false;
|
||||||
|
break;
|
||||||
|
case '--min-severity':
|
||||||
|
parsed.minSeverity = args[++i];
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
if (arg.startsWith('-')) {
|
||||||
|
console.error(`Unknown option: ${arg}`);
|
||||||
|
process.exit(1);
|
||||||
|
} else {
|
||||||
|
parsed.paths.push(arg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printHelp(): void {
|
||||||
|
console.log(`
|
||||||
|
Vibe-Coded SaaS Security Scanner
|
||||||
|
Automated security scanner for vibe-coded / AI-generated SaaS apps.
|
||||||
|
|
||||||
|
Usage: vibe-scan [options] <paths...>
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-h, --help Show this help
|
||||||
|
-v, --version Show version
|
||||||
|
-f, --format <fmt> Output format: text (default), json, sarif, markdown
|
||||||
|
-o, --output <file> Write output to file instead of stdout
|
||||||
|
-e, --exclude <pattern> Exclude paths matching pattern (repeatable)
|
||||||
|
-r, --rules <ids> Comma-separated rule IDs to run
|
||||||
|
-c, --categories <cats> Comma-separated categories: secret,injection,validation,client-side,config
|
||||||
|
-s, --severities <sevs> Comma-separated severities: critical,high,medium,low
|
||||||
|
--max-file-size <bytes> Skip files larger than this (default: 1MB)
|
||||||
|
--no-gitignore Do not respect .gitignore files
|
||||||
|
--no-snippet Hide code snippets in output
|
||||||
|
--min-severity <sev> Only show findings at or above this severity
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
vibe-scan .
|
||||||
|
vibe-scan --format json --output report.json ./src
|
||||||
|
vibe-scan --categories secret --min-severity high ./src ./lib
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function printVersion(): void {
|
||||||
|
console.log('vibe-coded-saas-security-scanner v1.0.0');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const args = parseArgs();
|
||||||
|
|
||||||
|
if (args.help) {
|
||||||
|
printHelp();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.version) {
|
||||||
|
printVersion();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.paths.length === 0) {
|
||||||
|
console.error('Error: No paths specified. Use --help for usage.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedPaths = args.paths.map((p) => resolve(p));
|
||||||
|
|
||||||
|
console.log('🔍 Vibe-Coded SaaS Security Scanner');
|
||||||
|
console.log(' Scanning:', resolvedPaths.join(', '));
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
const result = await scan({
|
||||||
|
paths: resolvedPaths,
|
||||||
|
exclude: args.exclude,
|
||||||
|
rules: args.rules,
|
||||||
|
categories: args.categories,
|
||||||
|
severities: args.severities,
|
||||||
|
maxFileSize: args.maxFileSize,
|
||||||
|
respectGitignore: !args.noGitignore,
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = report(result, {
|
||||||
|
format: args.format,
|
||||||
|
output: args.output,
|
||||||
|
showSnippet: args.showSnippet,
|
||||||
|
minSeverity: args.minSeverity,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (args.output) {
|
||||||
|
await writeFile(args.output, output, 'utf-8');
|
||||||
|
console.log(`\nReport written to ${args.output}`);
|
||||||
|
} else {
|
||||||
|
console.log(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exit with non-zero if critical findings exist
|
||||||
|
const hasCritical = result.findings.some((f) => f.severity === 'critical');
|
||||||
|
if (hasCritical) {
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Error:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { scan, type ScanOptions, type ScanResult, type Finding } from './scanner.js';
|
||||||
|
export { report, type Format, type ReporterOptions } from './reporter.js';
|
||||||
|
export { rules, type Rule, getRulesByCategory, getRulesBySeverity } from './rules.js';
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { report } from '../src/reporter.js';
|
||||||
|
import type { ScanResult, Finding } from '../src/scanner.js';
|
||||||
|
|
||||||
|
function makeResult(findings: Finding[]): ScanResult {
|
||||||
|
return {
|
||||||
|
findings,
|
||||||
|
filesScanned: 3,
|
||||||
|
filesSkipped: 0,
|
||||||
|
rulesRun: 10,
|
||||||
|
durationMs: 42,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const sampleFinding: Finding = {
|
||||||
|
ruleId: 'SEC-001',
|
||||||
|
ruleName: 'Exposed Stripe Secret Key',
|
||||||
|
severity: 'critical',
|
||||||
|
category: 'secret',
|
||||||
|
description: 'Stripe secret key detected in source.',
|
||||||
|
file: '/src/payments.js',
|
||||||
|
line: 5,
|
||||||
|
column: 20,
|
||||||
|
snippet: "...require('stripe')('sk_live_...')...",
|
||||||
|
match: "sk_live_abcdefghijklmnopqrstuvwxyz",
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('reporter', () => {
|
||||||
|
it('should format text output', () => {
|
||||||
|
const result = makeResult([sampleFinding]);
|
||||||
|
const output = report(result, { format: 'text' });
|
||||||
|
expect(output).toContain('Exposed Stripe Secret Key');
|
||||||
|
expect(output).toContain('CRITICAL');
|
||||||
|
expect(output).toContain('Files scanned: 3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format JSON output', () => {
|
||||||
|
const result = makeResult([sampleFinding]);
|
||||||
|
const output = report(result, { format: 'json' });
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
expect(parsed.version).toBe('1.0.0');
|
||||||
|
expect(parsed.findings.length).toBe(1);
|
||||||
|
expect(parsed.findings[0].ruleId).toBe('SEC-001');
|
||||||
|
expect(parsed.summary.filesScanned).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format markdown output', () => {
|
||||||
|
const result = makeResult([sampleFinding]);
|
||||||
|
const output = report(result, { format: 'markdown' });
|
||||||
|
expect(output).toContain('# Vibe-Coded SaaS Security Scanner Report');
|
||||||
|
expect(output).toContain('### /src/payments.js');
|
||||||
|
expect(output).toContain('Exposed Stripe Secret Key');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format SARIF output', () => {
|
||||||
|
const result = makeResult([sampleFinding]);
|
||||||
|
const output = report(result, { format: 'sarif' });
|
||||||
|
const parsed = JSON.parse(output);
|
||||||
|
expect(parsed.version).toBe('2.1.0');
|
||||||
|
expect(parsed.runs[0].results.length).toBe(1);
|
||||||
|
expect(parsed.runs[0].results[0].level).toBe('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by min severity', () => {
|
||||||
|
const findings: Finding[] = [
|
||||||
|
{ ...sampleFinding, severity: 'critical' },
|
||||||
|
{ ...sampleFinding, ruleId: 'SEC-002', severity: 'low', ruleName: 'Low Priority', description: 'low' },
|
||||||
|
];
|
||||||
|
const result = makeResult(findings);
|
||||||
|
const output = report(result, { format: 'text', minSeverity: 'high' });
|
||||||
|
expect(output).toContain('CRITICAL');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show empty results', () => {
|
||||||
|
const result = makeResult([]);
|
||||||
|
const output = report(result, { format: 'text' });
|
||||||
|
expect(output).toContain('No security issues found');
|
||||||
|
});
|
||||||
|
});
|
||||||
+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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+455
@@ -0,0 +1,455 @@
|
|||||||
|
export interface Rule {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||||
|
category: 'secret' | 'injection' | 'validation' | 'client-side' | 'config';
|
||||||
|
description: string;
|
||||||
|
patterns: RegExp[];
|
||||||
|
fileFilter?: (filename: string) => boolean;
|
||||||
|
exclude?: RegExp[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function codeFileFilter(filename: string): boolean {
|
||||||
|
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||||
|
const codeExts = ['js', 'ts', 'jsx', 'tsx', 'py', 'rb', 'go', 'java', 'php', 'cs', 'rs', 'c', 'cpp', 'h', 'swift', 'kt', 'scala', 'html', 'vue', 'svelte'];
|
||||||
|
return codeExts.includes(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rules: Rule[] = [
|
||||||
|
// === SECRETS / API KEYS ===
|
||||||
|
{
|
||||||
|
id: 'SEC-001',
|
||||||
|
name: 'Exposed Stripe Secret Key',
|
||||||
|
severity: 'critical',
|
||||||
|
category: 'secret',
|
||||||
|
description: 'Stripe secret key (sk_live_ or sk_test_) detected in source code. These grant full API access to your Stripe account.',
|
||||||
|
patterns: [
|
||||||
|
/sk_live_[a-zA-Z0-9]{24,}/g,
|
||||||
|
/sk_test_[a-zA-Z0-9]{24,}/g,
|
||||||
|
/["']sk_live_[a-zA-Z0-9]{24,}["']/g,
|
||||||
|
/["']sk_test_[a-zA-Z0-9]{24,}["']/g,
|
||||||
|
],
|
||||||
|
exclude: [/process\.env\./, /import\.meta\.env\./, /ENV\[/, /\$env\./],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'SEC-002',
|
||||||
|
name: 'Exposed Stripe Publishable Key in Backend',
|
||||||
|
severity: 'medium',
|
||||||
|
category: 'secret',
|
||||||
|
description: 'Stripe publishable key (pk_live_) found in non-frontend code. While not secret, should be in environment config.',
|
||||||
|
patterns: [
|
||||||
|
/pk_live_[a-zA-Z0-9]{24,}/g,
|
||||||
|
/pk_test_[a-zA-Z0-9]{24,}/g,
|
||||||
|
],
|
||||||
|
fileFilter: (f) => !f.includes('frontend') && !f.includes('client') && !f.endsWith('.html') && !f.endsWith('.jsx') && !f.endsWith('.tsx') && !f.endsWith('.vue'),
|
||||||
|
exclude: [/process\.env\./, /import\.meta\.env\./, /ENV\[/, /\$env\./],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'SEC-003',
|
||||||
|
name: 'Exposed AWS Access Key ID',
|
||||||
|
severity: 'critical',
|
||||||
|
category: 'secret',
|
||||||
|
description: 'AWS Access Key ID (AKIA...) detected in source code. Paired with a secret key, this grants AWS account access.',
|
||||||
|
patterns: [
|
||||||
|
/AKIA[0-9A-Z]{16}/g,
|
||||||
|
/["']AKIA[0-9A-Z]{16}["']/g,
|
||||||
|
],
|
||||||
|
exclude: [/process\.env\./, /import\.meta\.env\./, /ENV\[/, /\$env\./, /example/, /placeholder/, /dummy/],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'SEC-004',
|
||||||
|
name: 'Exposed AWS Secret Access Key',
|
||||||
|
severity: 'critical',
|
||||||
|
category: 'secret',
|
||||||
|
description: 'AWS Secret Access Key pattern detected in source code. Combined with Access Key ID, grants full AWS account access.',
|
||||||
|
patterns: [
|
||||||
|
/aws_secret_access_key\s*[:=]\s*["'][a-zA-Z0-9/+=]{40}["']/gi,
|
||||||
|
/secret_access_key\s*[:=]\s*["'][a-zA-Z0-9/+=]{40}["']/gi,
|
||||||
|
],
|
||||||
|
exclude: [/process\.env\./, /import\.meta\.env\./, /ENV\[/, /\$env\./, /example/, /placeholder/, /dummy/],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'SEC-005',
|
||||||
|
name: 'Exposed OpenAI API Key',
|
||||||
|
severity: 'critical',
|
||||||
|
category: 'secret',
|
||||||
|
description: 'OpenAI API key (sk-...) detected in source code. Grants access to GPT models and billing.',
|
||||||
|
patterns: [
|
||||||
|
/sk-[a-zA-Z0-9]{48}/g,
|
||||||
|
/sk-proj-[a-zA-Z0-9-_]{100,}/g,
|
||||||
|
/sk-[a-zA-Z0-9]{20,}/g,
|
||||||
|
],
|
||||||
|
exclude: [/process\.env\./, /import\.meta\.env\./, /ENV\[/, /\$env\./, /example/, /placeholder/, /dummy/],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'SEC-006',
|
||||||
|
name: 'Exposed Generic API Key / Token',
|
||||||
|
severity: 'high',
|
||||||
|
category: 'secret',
|
||||||
|
description: 'Generic API key or token pattern detected. May be a service credential hardcoded in source.',
|
||||||
|
patterns: [
|
||||||
|
/api[_-]?key\s*[:=]\s*["'][a-zA-Z0-9_-]{32,}["']/gi,
|
||||||
|
/apikey\s*[:=]\s*["'][a-zA-Z0-9_-]{32,}["']/gi,
|
||||||
|
/api[_-]?token\s*[:=]\s*["'][a-zA-Z0-9_-]{32,}["']/gi,
|
||||||
|
/auth[_-]?token\s*[:=]\s*["'][a-zA-Z0-9_-]{32,}["']/gi,
|
||||||
|
/bearer\s+[a-zA-Z0-9_-]{32,}/gi,
|
||||||
|
],
|
||||||
|
exclude: [/process\.env\./, /import\.meta\.env\./, /ENV\[/, /\$env\./, /example/, /placeholder/, /dummy/, /mock/, /test/],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'SEC-007',
|
||||||
|
name: 'Exposed Database Connection String',
|
||||||
|
severity: 'critical',
|
||||||
|
category: 'secret',
|
||||||
|
description: 'Database connection string with credentials detected in source code.',
|
||||||
|
patterns: [
|
||||||
|
/(postgres|mysql|mongodb|redis)[:/][/][^/\s:]+:[^/\s@]+@[a-zA-Z0-9.-]+/gi,
|
||||||
|
/DATABASE_URL\s*[:=]\s*["'][^"']+\/\/[^"']+:[^"']+@[^"']+/gi,
|
||||||
|
/mongodb\+srv:\/\/[^/\s:]+:[^/\s@]+@/gi,
|
||||||
|
],
|
||||||
|
exclude: [/process\.env\./, /import\.meta\.env\./, /ENV\[/, /\$env\./, /localhost/, /127\.0\.0\.1/, /example/, /placeholder/, /dummy/],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'SEC-008',
|
||||||
|
name: 'Exposed Twilio Auth Token',
|
||||||
|
severity: 'critical',
|
||||||
|
category: 'secret',
|
||||||
|
description: 'Twilio Auth Token detected in source code. Grants SMS/voice account access.',
|
||||||
|
patterns: [
|
||||||
|
/twilio_auth_token\s*[:=]\s*["'][a-f0-9]{32}["']/gi,
|
||||||
|
/auth_token\s*[:=]\s*["'][a-f0-9]{32}["']/gi,
|
||||||
|
],
|
||||||
|
exclude: [/process\.env\./, /import\.meta\.env\./, /ENV\[/, /\$env\./, /example/, /placeholder/, /dummy/],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'SEC-009',
|
||||||
|
name: 'Exposed SendGrid API Key',
|
||||||
|
severity: 'high',
|
||||||
|
category: 'secret',
|
||||||
|
description: 'SendGrid API key (SG.xxx) detected in source code. Grants email sending access.',
|
||||||
|
patterns: [
|
||||||
|
/SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43}/g,
|
||||||
|
],
|
||||||
|
exclude: [/process\.env\./, /import\.meta\.env\./, /ENV\[/, /\$env\./, /example/, /placeholder/, /dummy/],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'SEC-010',
|
||||||
|
name: 'Exposed GitHub Personal Access Token',
|
||||||
|
severity: 'critical',
|
||||||
|
category: 'secret',
|
||||||
|
description: 'GitHub Personal Access Token (ghp_ or github_pat_) detected in source code.',
|
||||||
|
patterns: [
|
||||||
|
/ghp_[a-zA-Z0-9]{36}/g,
|
||||||
|
/github_pat_[a-zA-Z0-9_-]{22}_[a-zA-Z0-9]{59}/g,
|
||||||
|
/gho_[a-zA-Z0-9]{36}/g,
|
||||||
|
],
|
||||||
|
exclude: [/process\.env\./, /import\.meta\.env\./, /ENV\[/, /\$env\./, /example/, /placeholder/, /dummy/],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'SEC-011',
|
||||||
|
name: 'Exposed JWT Secret',
|
||||||
|
severity: 'critical',
|
||||||
|
category: 'secret',
|
||||||
|
description: 'JWT signing secret detected in source code. Attackers can forge tokens with this.',
|
||||||
|
patterns: [
|
||||||
|
/jwt[_-]?secret\s*[:=]\s*["'][^"']{16,}["']/gi,
|
||||||
|
/jwt[_-]?key\s*[:=]\s*["'][^"']{16,}["']/gi,
|
||||||
|
/secret[_-]?key\s*[:=]\s*["'][^"']{16,}["']/gi,
|
||||||
|
/SESSION_SECRET\s*[:=]\s*["'][^"']{16,}["']/gi,
|
||||||
|
],
|
||||||
|
exclude: [/process\.env\./, /import\.meta\.env\./, /ENV\[/, /\$env\./, /example/, /placeholder/, /dummy/, /generateSecret/, /crypto\.random/],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'SEC-012',
|
||||||
|
name: 'Exposed Private Key',
|
||||||
|
severity: 'critical',
|
||||||
|
category: 'secret',
|
||||||
|
description: 'Private key material detected in source code. This is the most critical secret exposure.',
|
||||||
|
patterns: [
|
||||||
|
/-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g,
|
||||||
|
/-----BEGIN RSA PRIVATE KEY-----/g,
|
||||||
|
/-----BEGIN EC PRIVATE KEY-----/g,
|
||||||
|
/-----BEGIN OPENSSH PRIVATE KEY-----/g,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'SEC-013',
|
||||||
|
name: 'Exposed Password in Source',
|
||||||
|
severity: 'critical',
|
||||||
|
category: 'secret',
|
||||||
|
description: 'Hardcoded password detected in source code.',
|
||||||
|
patterns: [
|
||||||
|
/password\s*[:=]\s*["'][^"']{8,}["']/gi,
|
||||||
|
/passwd\s*[:=]\s*["'][^"']{8,}["']/gi,
|
||||||
|
/pwd\s*[:=]\s*["'][^"']{8,}["']/gi,
|
||||||
|
],
|
||||||
|
exclude: [/process\.env\./, /import\.meta\.env\./, /ENV\[/, /\$env\./, /example/, /placeholder/, /dummy/, /password123/, /admin123/, /12345678/, /password\s*[:=]\s*["']\*+["']/],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'SEC-014',
|
||||||
|
name: 'Exposed Slack Token',
|
||||||
|
severity: 'high',
|
||||||
|
category: 'secret',
|
||||||
|
description: 'Slack token (xoxb- or xoxp-) detected in source code.',
|
||||||
|
patterns: [
|
||||||
|
/xoxb-[a-zA-Z0-9-]{18,}/g,
|
||||||
|
/xoxp-[a-zA-Z0-9-]{18,}/g,
|
||||||
|
/xoxa-[a-zA-Z0-9-]{18,}/g,
|
||||||
|
],
|
||||||
|
exclude: [/process\.env\./, /import\.meta\.env\./, /ENV\[/, /\$env\./, /example/, /placeholder/, /dummy/],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'SEC-015',
|
||||||
|
name: 'Exposed Firebase Config with API Key',
|
||||||
|
severity: 'high',
|
||||||
|
category: 'secret',
|
||||||
|
description: 'Firebase configuration object with API key detected in client-side code. While Firebase keys are less sensitive, they should not be exposed unnecessarily.',
|
||||||
|
patterns: [
|
||||||
|
/apiKey\s*:\s*["'][A-Za-z0-9_-]{39}["']/g,
|
||||||
|
],
|
||||||
|
fileFilter: (f) => f.endsWith('.js') || f.endsWith('.ts') || f.endsWith('.jsx') || f.endsWith('.tsx') || f.endsWith('.html') || f.endsWith('.vue') || f.endsWith('.svelte'),
|
||||||
|
},
|
||||||
|
// === INJECTION VULNERABILITIES ===
|
||||||
|
{
|
||||||
|
id: 'INJ-001',
|
||||||
|
name: 'SQL Injection Vulnerability',
|
||||||
|
severity: 'critical',
|
||||||
|
category: 'injection',
|
||||||
|
description: 'Potential SQL injection: user input concatenated into SQL query without parameterization.',
|
||||||
|
patterns: [
|
||||||
|
/query\s*\(\s*[`"'].*\$\{.*\}.*[`"']/g,
|
||||||
|
/query\s*\(\s*["'].*\+.*\+.*["']/g,
|
||||||
|
/exec\s*\(\s*[`"'].*\$\{.*\}.*[`"']/g,
|
||||||
|
/execute\s*\(\s*[`"'].*\$\{.*\}.*[`"']/g,
|
||||||
|
/\.query\s*\(\s*[`"'].*\+.*\+.*[`"']/g,
|
||||||
|
/SELECT.*FROM.*\+.*\+.*WHERE/gi,
|
||||||
|
/INSERT\s+INTO.*\+.*\+/gi,
|
||||||
|
/UPDATE\s+.*SET.*\+.*\+/gi,
|
||||||
|
/DELETE\s+FROM.*\+.*\+/gi,
|
||||||
|
],
|
||||||
|
fileFilter: codeFileFilter,
|
||||||
|
exclude: [/\?.*\?/, /\$\d+/, /:%s/, /placeholder/, /parameterized/, /prepare/],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'INJ-002',
|
||||||
|
name: 'NoSQL Injection Vulnerability',
|
||||||
|
severity: 'high',
|
||||||
|
category: 'injection',
|
||||||
|
description: 'Potential NoSQL injection: user input used directly in MongoDB/NoSQL query object.',
|
||||||
|
patterns: [
|
||||||
|
/find\s*\(\s*\{.*\$where\s*:/g,
|
||||||
|
/find\s*\(\s*\{.*\$eq\s*:\s*req\./g,
|
||||||
|
/find\s*\(\s*\{.*\$in\s*:\s*req\./g,
|
||||||
|
/findOne\s*\(\s*\{.*req\.(body|query|params)/g,
|
||||||
|
/find\s*\(\s*\{.*req\.(body|query|params)/g,
|
||||||
|
],
|
||||||
|
fileFilter: codeFileFilter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'INJ-003',
|
||||||
|
name: 'Command Injection Vulnerability',
|
||||||
|
severity: 'critical',
|
||||||
|
category: 'injection',
|
||||||
|
description: 'Potential command injection: user input passed to shell execution functions.',
|
||||||
|
patterns: [
|
||||||
|
/exec\s*\(\s*[`"'].*\$\{.*\}.*[`"']/g,
|
||||||
|
/execSync\s*\(\s*[`"'].*\$\{.*\}.*[`"']/g,
|
||||||
|
/spawn\s*\(\s*["'].*req\./g,
|
||||||
|
/child_process.*exec.*req\./g,
|
||||||
|
/eval\s*\(.*req\./g,
|
||||||
|
],
|
||||||
|
fileFilter: codeFileFilter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'INJ-004',
|
||||||
|
name: 'Path Traversal Vulnerability',
|
||||||
|
severity: 'high',
|
||||||
|
category: 'injection',
|
||||||
|
description: 'Potential path traversal: user input used to construct file paths without sanitization.',
|
||||||
|
patterns: [
|
||||||
|
/fs\.readFile\s*\(\s*.*req\.(body|query|params)/g,
|
||||||
|
/fs\.writeFile\s*\(\s*.*req\.(body|query|params)/g,
|
||||||
|
/res\.sendFile\s*\(\s*.*req\./g,
|
||||||
|
/readFileSync\s*\(\s*.*\+.*req\./g,
|
||||||
|
/path\.join\s*\(.*req\./g,
|
||||||
|
],
|
||||||
|
fileFilter: codeFileFilter,
|
||||||
|
},
|
||||||
|
// === XSS / CLIENT-SIDE VULNERABILITIES ===
|
||||||
|
{
|
||||||
|
id: 'XSS-001',
|
||||||
|
name: 'DOM XSS: innerHTML with User Input',
|
||||||
|
severity: 'high',
|
||||||
|
category: 'client-side',
|
||||||
|
description: 'Potential DOM-based XSS: user input assigned to innerHTML without sanitization.',
|
||||||
|
patterns: [
|
||||||
|
/innerHTML\s*=\s*.*(req\.|location\.|search|hash|href)/g,
|
||||||
|
/innerHTML\s*=\s*.*\$\{.*\}/g,
|
||||||
|
/document\.write\s*\(.*(req\.|location\.|search|hash|href)/g,
|
||||||
|
/outerHTML\s*=\s*.*(req\.|location\.|search|hash|href)/g,
|
||||||
|
],
|
||||||
|
fileFilter: codeFileFilter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'XSS-002',
|
||||||
|
name: 'Dangerous eval() Usage',
|
||||||
|
severity: 'critical',
|
||||||
|
category: 'client-side',
|
||||||
|
description: 'eval() used with potentially user-controlled input. This is a severe security risk.',
|
||||||
|
patterns: [
|
||||||
|
/eval\s*\(\s*.*(req\.|location\.|search|hash|href|cookie)/g,
|
||||||
|
/eval\s*\(\s*.*\$\{.*\}/g,
|
||||||
|
/new\s+Function\s*\(.*(req\.|location\.|search|hash|href)/g,
|
||||||
|
/setTimeout\s*\(\s*["'].*\+.*["']/g,
|
||||||
|
/setInterval\s*\(\s*["'].*\+.*["']/g,
|
||||||
|
],
|
||||||
|
fileFilter: codeFileFilter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'XSS-003',
|
||||||
|
name: 'React dangerouslySetInnerHTML',
|
||||||
|
severity: 'medium',
|
||||||
|
category: 'client-side',
|
||||||
|
description: 'dangerouslySetInnerHTML used in React. Ensure content is sanitized before use.',
|
||||||
|
patterns: [
|
||||||
|
/dangerouslySetInnerHTML\s*:/g,
|
||||||
|
],
|
||||||
|
fileFilter: codeFileFilter,
|
||||||
|
},
|
||||||
|
// === MISSING INPUT VALIDATION ===
|
||||||
|
{
|
||||||
|
id: 'VAL-001',
|
||||||
|
name: 'Missing Input Validation on Express Route',
|
||||||
|
severity: 'medium',
|
||||||
|
category: 'validation',
|
||||||
|
description: 'Express route handler accesses req.body without visible validation. Add express-validator, joi, or zod.',
|
||||||
|
patterns: [
|
||||||
|
/app\.(post|put|patch)\s*\(\s*["'].*["'].*\(\s*req\s*,\s*res\s*\).*\{\s*[^}]*req\.body(?!.*validate)/gs,
|
||||||
|
/app\.(post|put|patch)\s*\(\s*["'].*["'].*\(\s*req\s*,\s*res\s*\).*\{\s*[^}]{0,500}\}[^}]*\)/gs,
|
||||||
|
],
|
||||||
|
fileFilter: codeFileFilter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'VAL-002',
|
||||||
|
name: 'Direct User Input in Object Assignment',
|
||||||
|
severity: 'medium',
|
||||||
|
category: 'validation',
|
||||||
|
description: 'User input directly spread into objects without validation. This can lead to mass assignment vulnerabilities.',
|
||||||
|
patterns: [
|
||||||
|
/\{\s*\.\.\.req\.body\s*\}/g,
|
||||||
|
/\.create\s*\(\s*req\.body\s*\)/g,
|
||||||
|
/\.insert\s*\(\s*req\.body\s*\)/g,
|
||||||
|
/Object\.assign\s*\(.*req\.body\)/g,
|
||||||
|
],
|
||||||
|
fileFilter: codeFileFilter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'VAL-003',
|
||||||
|
name: 'Missing CORS Configuration',
|
||||||
|
severity: 'medium',
|
||||||
|
category: 'config',
|
||||||
|
description: 'CORS enabled with wildcard or no explicit origin restriction. This allows any website to make requests to your API.',
|
||||||
|
patterns: [
|
||||||
|
/app\.use\s*\(\s*cors\s*\(\s*\)\s*\)/g,
|
||||||
|
/cors\s*\(\s*\{\s*origin\s*:\s*["']\*["']\s*\}\s*\)/g,
|
||||||
|
/Access-Control-Allow-Origin\s*:\s*\*/g,
|
||||||
|
],
|
||||||
|
fileFilter: codeFileFilter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'VAL-004',
|
||||||
|
name: 'Insecure Cookie Configuration',
|
||||||
|
severity: 'high',
|
||||||
|
category: 'config',
|
||||||
|
description: 'Cookie set without secure flags (httpOnly, secure, sameSite). Vulnerable to XSS and MITM theft.',
|
||||||
|
patterns: [
|
||||||
|
/res\.cookie\s*\(\s*[^,]+,\s*[^,]+\s*\)/g,
|
||||||
|
/cookie\s*\(\s*[^,]+,\s*[^,]+,\s*\{\s*[^}]*\}\s*\)/g,
|
||||||
|
],
|
||||||
|
fileFilter: codeFileFilter,
|
||||||
|
exclude: [/httpOnly/, /secure.*true/, /sameSite/, /Secure/],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'VAL-005',
|
||||||
|
name: 'Insecure CORS with Credentials',
|
||||||
|
severity: 'high',
|
||||||
|
category: 'config',
|
||||||
|
description: 'CORS configured with credentials:true but without explicit origin restriction. Dangerous combination.',
|
||||||
|
patterns: [
|
||||||
|
/credentials\s*:\s*true.*origin\s*:\s*["']\*["']/gs,
|
||||||
|
/origin\s*:\s*["']\*["'].*credentials\s*:\s*true/gs,
|
||||||
|
],
|
||||||
|
fileFilter: codeFileFilter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'VAL-006',
|
||||||
|
name: 'Debug Mode Enabled in Production Code',
|
||||||
|
severity: 'medium',
|
||||||
|
category: 'config',
|
||||||
|
description: 'DEBUG or development mode enabled in code. This can leak sensitive information.',
|
||||||
|
patterns: [
|
||||||
|
/DEBUG\s*[:=]\s*true/gi,
|
||||||
|
/NODE_ENV\s*[:=]\s*["']development["']/gi,
|
||||||
|
/app\.use\s*\(\s*errorhandler\s*\)/gi,
|
||||||
|
],
|
||||||
|
fileFilter: codeFileFilter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'VAL-007',
|
||||||
|
name: 'Insecure HTTP Usage',
|
||||||
|
severity: 'medium',
|
||||||
|
category: 'config',
|
||||||
|
description: 'Hardcoded HTTP URLs instead of HTTPS for API calls or resources.',
|
||||||
|
patterns: [
|
||||||
|
/http:\/\/(?!localhost|127\.0\.0\.1)/g,
|
||||||
|
],
|
||||||
|
fileFilter: codeFileFilter,
|
||||||
|
exclude: [/http:\/\/localhost/, /http:\/\/127\.0\.0\.1/],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'VAL-008',
|
||||||
|
name: 'Insecure Randomness for Security Tokens',
|
||||||
|
severity: 'high',
|
||||||
|
category: 'config',
|
||||||
|
description: 'Math.random() used for token or ID generation. Use crypto.randomBytes() or crypto.randomUUID() instead.',
|
||||||
|
patterns: [
|
||||||
|
/Math\.random\s*\(\s*\).*token/gi,
|
||||||
|
/Math\.random\s*\(\s*\).*id/gi,
|
||||||
|
/Math\.random\s*\(\s*\).*secret/gi,
|
||||||
|
/Math\.random\s*\(\s*\).*password/gi,
|
||||||
|
/Math\.random\s*\(\s*\).*session/gi,
|
||||||
|
],
|
||||||
|
fileFilter: codeFileFilter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'VAL-009',
|
||||||
|
name: 'Missing Rate Limiting on API Routes',
|
||||||
|
severity: 'medium',
|
||||||
|
category: 'validation',
|
||||||
|
description: 'No rate limiting middleware detected in Express/Fastify app. API routes are vulnerable to brute force and DoS.',
|
||||||
|
patterns: [
|
||||||
|
/app\.(get|post|put|delete|patch)\s*\(\s*["']\/api\//g,
|
||||||
|
],
|
||||||
|
fileFilter: codeFileFilter,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'VAL-010',
|
||||||
|
name: 'Insecure File Upload',
|
||||||
|
severity: 'high',
|
||||||
|
category: 'validation',
|
||||||
|
description: 'File upload without extension or type validation. Could allow executable uploads.',
|
||||||
|
patterns: [
|
||||||
|
/multer\s*\(\s*\{\s*\}\s*\)/g,
|
||||||
|
/upload\s*\.single\s*\(.*\)\s*[^}]*[^a-zA-Z](?!.*\.ext|.*\.type|.*mimetype|.*extension)/g,
|
||||||
|
],
|
||||||
|
fileFilter: codeFileFilter,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function getRulesByCategory(category: string): Rule[] {
|
||||||
|
return rules.filter((r) => r.category === category);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRulesBySeverity(severity: string): Rule[] {
|
||||||
|
return rules.filter((r) => r.severity === severity);
|
||||||
|
}
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||||
|
import { writeFile, mkdir, rm } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { scan } from '../src/scanner.js';
|
||||||
|
|
||||||
|
const TEST_DIR = '/tmp/vibe-scan-test-' + Date.now();
|
||||||
|
|
||||||
|
async function setupTestFiles(): Promise<void> {
|
||||||
|
await mkdir(TEST_DIR, { recursive: true });
|
||||||
|
await mkdir(join(TEST_DIR, 'src'), { recursive: true });
|
||||||
|
await mkdir(join(TEST_DIR, 'node_modules'), { recursive: true });
|
||||||
|
|
||||||
|
// File with exposed Stripe key
|
||||||
|
await writeFile(
|
||||||
|
join(TEST_DIR, 'src', 'payments.js'),
|
||||||
|
`const stripe = require('stripe')('sk_live_abcdefghijklmnopqrstuvwxyz');\n\nexport async function charge(amount) {\n return stripe.charges.create({ amount });\n}\n`
|
||||||
|
);
|
||||||
|
|
||||||
|
// File with SQL injection
|
||||||
|
await writeFile(
|
||||||
|
join(TEST_DIR, 'src', 'database.ts'),
|
||||||
|
`import { pool } from './db';\n\nexport async function getUser(id: string) {\n const result = await pool.query(\`SELECT * FROM users WHERE id = \${id}\`);\n return result.rows[0];\n}\n`
|
||||||
|
);
|
||||||
|
|
||||||
|
// File with exposed AWS key
|
||||||
|
await writeFile(
|
||||||
|
join(TEST_DIR, 'src', 'config.js'),
|
||||||
|
`module.exports = {\n awsAccessKey: 'AKIAIOSFODNN7EXAMPLE',\n region: 'us-east-1'\n};\n`
|
||||||
|
);
|
||||||
|
|
||||||
|
// File with XSS vulnerability
|
||||||
|
await writeFile(
|
||||||
|
join(TEST_DIR, 'src', 'render.js'),
|
||||||
|
`function displayUserInput(input) {\n document.getElementById('output').innerHTML = \`\${location.search}\`;\n}\n`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Binary file (should be skipped)
|
||||||
|
await writeFile(
|
||||||
|
join(TEST_DIR, 'src', 'image.png'),
|
||||||
|
Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clean file
|
||||||
|
await writeFile(
|
||||||
|
join(TEST_DIR, 'src', 'utils.ts'),
|
||||||
|
`export function add(a: number, b: number): number {\n return a + b;\n}\n`
|
||||||
|
);
|
||||||
|
|
||||||
|
// File in node_modules (should be ignored)
|
||||||
|
await writeFile(
|
||||||
|
join(TEST_DIR, 'node_modules', 'evil.js'),
|
||||||
|
`const key = 'sk_live_abcdefghijklmnopqrstuvwxyz';\n`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanup(): Promise<void> {
|
||||||
|
await rm(TEST_DIR, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('scanner', () => {
|
||||||
|
beforeAll(setupTestFiles);
|
||||||
|
afterAll(cleanup);
|
||||||
|
|
||||||
|
it('should detect exposed Stripe secret key', async () => {
|
||||||
|
const result = await scan({ paths: [TEST_DIR] });
|
||||||
|
const stripeFindings = result.findings.filter((f) => f.ruleId === 'SEC-001');
|
||||||
|
expect(stripeFindings.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(stripeFindings[0].severity).toBe('critical');
|
||||||
|
expect(stripeFindings[0].file).toContain('payments.js');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect SQL injection', async () => {
|
||||||
|
const result = await scan({ paths: [TEST_DIR] });
|
||||||
|
const sqlFindings = result.findings.filter((f) => f.ruleId === 'INJ-001');
|
||||||
|
expect(sqlFindings.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(sqlFindings[0].severity).toBe('critical');
|
||||||
|
expect(sqlFindings[0].file).toContain('database.ts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect exposed AWS key', async () => {
|
||||||
|
const result = await scan({ paths: [TEST_DIR] });
|
||||||
|
const awsFindings = result.findings.filter((f) => f.ruleId === 'SEC-003');
|
||||||
|
expect(awsFindings.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(awsFindings[0].severity).toBe('critical');
|
||||||
|
expect(awsFindings[0].file).toContain('config.js');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect XSS vulnerability', async () => {
|
||||||
|
const result = await scan({ paths: [TEST_DIR] });
|
||||||
|
const xssFindings = result.findings.filter((f) => f.ruleId === 'XSS-001');
|
||||||
|
expect(xssFindings.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(xssFindings[0].severity).toBe('high');
|
||||||
|
expect(xssFindings[0].file).toContain('render.js');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not flag node_modules by default', async () => {
|
||||||
|
const result = await scan({ paths: [TEST_DIR] });
|
||||||
|
const nodeModulesFindings = result.findings.filter((f) => f.file.includes('node_modules'));
|
||||||
|
expect(nodeModulesFindings.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should count files correctly', async () => {
|
||||||
|
const result = await scan({ paths: [TEST_DIR] });
|
||||||
|
expect(result.filesScanned).toBe(5); // 5 files in src/
|
||||||
|
expect(result.filesSkipped).toBeGreaterThanOrEqual(1); // node_modules files skipped
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by category', async () => {
|
||||||
|
const result = await scan({ paths: [TEST_DIR], categories: ['secret'] });
|
||||||
|
const nonSecret = result.findings.filter((f) => f.category !== 'secret');
|
||||||
|
expect(nonSecret.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter by severity', async () => {
|
||||||
|
const result = await scan({ paths: [TEST_DIR], severities: ['critical'] });
|
||||||
|
const nonCritical = result.findings.filter((f) => f.severity !== 'critical');
|
||||||
|
expect(nonCritical.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty for clean directory', async () => {
|
||||||
|
const cleanDir = join(TEST_DIR, 'src');
|
||||||
|
// Create a temp clean file
|
||||||
|
const cleanFile = join(cleanDir, 'clean-test.ts');
|
||||||
|
await writeFile(cleanFile, `export const foo = 42;\n`);
|
||||||
|
const result = await scan({ paths: [cleanFile] });
|
||||||
|
expect(result.findings.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should deduplicate findings', async () => {
|
||||||
|
// Write a file with the same pattern twice on the same line
|
||||||
|
const dupFile = join(TEST_DIR, 'src', 'dup.js');
|
||||||
|
await writeFile(dupFile, `const a = 'sk_live_abcdefghijklmnopqrstuvwxyz'; const b = 'sk_live_abcdefghijklmnopqrstuvwxyz';\n`);
|
||||||
|
const result = await scan({ paths: [dupFile] });
|
||||||
|
// Should still find at least 1, but not duplicate the same rule on same line
|
||||||
|
const stripeFindings = result.findings.filter((f) => f.ruleId === 'SEC-001');
|
||||||
|
expect(stripeFindings.length).toBeLessThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
+288
@@ -0,0 +1,288 @@
|
|||||||
|
import { Rule, rules } from './rules.js';
|
||||||
|
|
||||||
|
export interface Finding {
|
||||||
|
ruleId: string;
|
||||||
|
ruleName: string;
|
||||||
|
severity: 'critical' | 'high' | 'medium' | 'low';
|
||||||
|
category: string;
|
||||||
|
description: string;
|
||||||
|
file: string;
|
||||||
|
line: number;
|
||||||
|
column: number;
|
||||||
|
snippet: string;
|
||||||
|
match: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanOptions {
|
||||||
|
paths: string[];
|
||||||
|
exclude?: string[];
|
||||||
|
rules?: string[];
|
||||||
|
categories?: string[];
|
||||||
|
severities?: string[];
|
||||||
|
maxFileSize?: number;
|
||||||
|
respectGitignore?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanResult {
|
||||||
|
findings: Finding[];
|
||||||
|
filesScanned: number;
|
||||||
|
filesSkipped: number;
|
||||||
|
rulesRun: number;
|
||||||
|
durationMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node.js imports for file system operations
|
||||||
|
import { readFile, stat, readdir, access } from 'node:fs/promises';
|
||||||
|
import { join, relative, dirname } from 'node:path';
|
||||||
|
import { createReadStream } from 'node:fs';
|
||||||
|
import { createInterface } from 'node:readline';
|
||||||
|
|
||||||
|
const DEFAULT_MAX_FILE_SIZE = 1024 * 1024; // 1MB
|
||||||
|
const TEXT_EXTENSIONS = new Set([
|
||||||
|
'js', 'ts', 'jsx', 'tsx', 'mjs', 'cjs', 'py', 'rb', 'go', 'java', 'php', 'cs', 'rs', 'c', 'cpp', 'h', 'hpp', 'swift', 'kt', 'scala', 'html', 'css', 'scss', 'sass', 'less', 'vue', 'svelte', 'json', 'yaml', 'yml', 'toml', 'xml', 'sql', 'md', 'sh', 'bash', 'zsh', 'ps1', 'Dockerfile', 'env', 'config', 'ini', 'properties', 'tf', 'hcl', 'graphql', 'prisma', 'sql',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const BINARY_EXTENSIONS = new Set([
|
||||||
|
'exe', 'dll', 'so', 'dylib', 'bin', 'dat', 'db', 'sqlite', 'sqlite3', 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'ico', 'bmp', 'mp3', 'mp4', 'avi', 'mov', 'wmv', 'flv', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'zip', 'tar', 'gz', 'bz2', '7z', 'rar', 'jar', 'war', 'ear', 'class', 'o', 'a', 'obj', 'pdb', 'wasm', 'map',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isTextFile(filename: string): boolean {
|
||||||
|
const ext = filename.split('.').pop()?.toLowerCase() || '';
|
||||||
|
if (BINARY_EXTENSIONS.has(ext)) return false;
|
||||||
|
if (TEXT_EXTENSIONS.has(ext)) return true;
|
||||||
|
// Default to scanning unknown extensions
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shouldIgnore(file: string, exclude: string[], basePath: string): Promise<boolean> {
|
||||||
|
const rel = relative(basePath, file);
|
||||||
|
for (const pattern of exclude) {
|
||||||
|
// Match exact segments or starts/ends with pattern
|
||||||
|
const segments = rel.split(/[\\/]/);
|
||||||
|
for (const seg of segments) {
|
||||||
|
if (seg === pattern || seg.startsWith(pattern + '.') || seg.startsWith(pattern + '-')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also check if the filename itself matches
|
||||||
|
const filename = file.split(/[\\/]/).pop() || '';
|
||||||
|
if (filename === pattern) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hasGitignoreFile(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await access(join(path, '.gitignore'));
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function parseGitignore(path: string): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const content = await readFile(join(path, '.gitignore'), 'utf-8');
|
||||||
|
return content
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter((l) => l && !l.startsWith('#'))
|
||||||
|
.map((l) => l.replace(/^\//, '').replace(/\/$/, ''));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getFilesToScan(
|
||||||
|
paths: string[],
|
||||||
|
exclude: string[],
|
||||||
|
respectGitignore: boolean,
|
||||||
|
maxFileSize: number
|
||||||
|
): Promise<{ files: string[]; skipped: number }> {
|
||||||
|
const files: string[] = [];
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const p of paths) {
|
||||||
|
const s = await stat(p);
|
||||||
|
if (s.isFile()) {
|
||||||
|
if (!isTextFile(p)) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (s.size > maxFileSize) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
files.push(p);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!s.isDirectory()) continue;
|
||||||
|
|
||||||
|
let gitignorePatterns: string[] = [];
|
||||||
|
if (respectGitignore) {
|
||||||
|
gitignorePatterns = await parseGitignore(p);
|
||||||
|
}
|
||||||
|
const allExclude = [...exclude, ...gitignorePatterns, 'node_modules', '.git', 'dist', 'build', 'coverage', '.next', '.nuxt', '.vercel', '.output', 'vendor'];
|
||||||
|
|
||||||
|
async function scanDir(dir: string) {
|
||||||
|
const entries = await readdir(dir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const full = join(dir, entry.name);
|
||||||
|
if (await shouldIgnore(full, allExclude, p)) {
|
||||||
|
if (entry.isFile()) {
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
await scanDir(full);
|
||||||
|
} else if (entry.isFile()) {
|
||||||
|
if (!isTextFile(full)) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const fstat = await stat(full);
|
||||||
|
if (fstat.size > maxFileSize) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
files.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await scanDir(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { files, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
function findLineColumn(content: string, offset: number): { line: number; column: number } {
|
||||||
|
let line = 1;
|
||||||
|
let col = 1;
|
||||||
|
for (let i = 0; i < offset && i < content.length; i++) {
|
||||||
|
if (content[i] === '\n') {
|
||||||
|
line++;
|
||||||
|
col = 1;
|
||||||
|
} else {
|
||||||
|
col++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { line, column: col };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSnippet(content: string, offset: number, matchLen: number): string {
|
||||||
|
const start = Math.max(0, offset - 40);
|
||||||
|
const end = Math.min(content.length, offset + matchLen + 40);
|
||||||
|
let snippet = content.slice(start, end);
|
||||||
|
snippet = snippet.replace(/\s+/g, ' ').trim();
|
||||||
|
if (start > 0) snippet = '...' + snippet;
|
||||||
|
if (end < content.length) snippet = snippet + '...';
|
||||||
|
return snippet;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRule(content: string, filename: string, rule: Rule): Finding[] {
|
||||||
|
const findings: Finding[] = [];
|
||||||
|
|
||||||
|
if (rule.fileFilter && !rule.fileFilter(filename)) {
|
||||||
|
return findings;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const pattern of rule.patterns) {
|
||||||
|
// Reset lastIndex for global regexes
|
||||||
|
pattern.lastIndex = 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = pattern.exec(content)) !== null) {
|
||||||
|
const matchedText = match[0];
|
||||||
|
|
||||||
|
// Check exclude patterns
|
||||||
|
if (rule.exclude) {
|
||||||
|
let excluded = false;
|
||||||
|
for (const ex of rule.exclude) {
|
||||||
|
ex.lastIndex = 0;
|
||||||
|
if (ex.test(matchedText)) {
|
||||||
|
excluded = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (excluded) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const offset = match.index;
|
||||||
|
const { line, column } = findLineColumn(content, offset);
|
||||||
|
const snippet = getSnippet(content, offset, matchedText.length);
|
||||||
|
|
||||||
|
findings.push({
|
||||||
|
ruleId: rule.id,
|
||||||
|
ruleName: rule.name,
|
||||||
|
severity: rule.severity,
|
||||||
|
category: rule.category,
|
||||||
|
description: rule.description,
|
||||||
|
file: filename,
|
||||||
|
line,
|
||||||
|
column,
|
||||||
|
snippet,
|
||||||
|
match: matchedText.slice(0, 80),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return findings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scan(options: ScanOptions): Promise<ScanResult> {
|
||||||
|
const start = Date.now();
|
||||||
|
const exclude = options.exclude || ['node_modules', '.git', 'dist', 'build', 'coverage', '.next', '.nuxt', '.vercel', 'vendor', '.venv'];
|
||||||
|
const maxFileSize = options.maxFileSize || DEFAULT_MAX_FILE_SIZE;
|
||||||
|
const respectGitignore = options.respectGitignore !== false;
|
||||||
|
|
||||||
|
let activeRules = rules;
|
||||||
|
if (options.rules && options.rules.length > 0) {
|
||||||
|
activeRules = rules.filter((r) => options.rules!.includes(r.id));
|
||||||
|
}
|
||||||
|
if (options.categories && options.categories.length > 0) {
|
||||||
|
activeRules = activeRules.filter((r) => options.categories!.includes(r.category));
|
||||||
|
}
|
||||||
|
if (options.severities && options.severities.length > 0) {
|
||||||
|
activeRules = activeRules.filter((r) => options.severities!.includes(r.severity));
|
||||||
|
}
|
||||||
|
|
||||||
|
let { files, skipped } = await getFilesToScan(options.paths, exclude, respectGitignore, maxFileSize);
|
||||||
|
const findings: Finding[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const content = await readFile(file, 'utf-8');
|
||||||
|
for (const rule of activeRules) {
|
||||||
|
const fileFindings = applyRule(content, file, rule);
|
||||||
|
findings.push(...fileFindings);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate: same file + line + ruleId
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const deduped: Finding[] = [];
|
||||||
|
for (const f of findings) {
|
||||||
|
const key = `${f.file}:${f.line}:${f.ruleId}`;
|
||||||
|
if (!seen.has(key)) {
|
||||||
|
seen.add(key);
|
||||||
|
deduped.push(f);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
findings: deduped,
|
||||||
|
filesScanned: files.length,
|
||||||
|
filesSkipped: skipped,
|
||||||
|
rulesRun: activeRules.length,
|
||||||
|
durationMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { rules };
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"resolveJsonModule": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
globals: false,
|
||||||
|
environment: 'node',
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user