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
+12
View File
@@ -0,0 +1,12 @@
node_modules/
dist/
*.log
.verdict
.venv/
.vite/
.DS_Store
*.swp
*.swo
*~
.idea/
.vscode/
+77
View File
@@ -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
+1887
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -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
View File
@@ -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);
});
+3
View File
@@ -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';
+79
View File
@@ -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
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);
}
}
+455
View File
@@ -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);
}
+138
View File
@@ -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
View File
@@ -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 };
+20
View File
@@ -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"]
}
+9
View File
@@ -0,0 +1,9 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: false,
environment: 'node',
include: ['src/**/*.test.ts'],
},
});