ubon
Version:
Security scanner for AI-generated apps (Cursor, Lovable, Windsurf, v0). Catches hardcoded secrets, prompt injection, hallucinated imports, Server Actions / Edge runtime mistakes, and the vibe-coded vulnerabilities traditional linters miss.
324 lines • 14.6 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.VibeScanner = void 0;
const fs_1 = require("fs");
const path_1 = require("path");
const base_scanner_1 = require("./base-scanner");
const rules_1 = require("../rules");
const tsconfig_resolver_1 = require("../utils/tsconfig-resolver");
/**
* VibeScanner: Detects patterns common in AI-generated "vibe coded" applications
*
* Rules:
* - VIBE001: Hallucinated imports (module not in package.json)
* - VIBE002: Copy-paste artifacts (repeated blocks)
* - VIBE003: Incomplete implementations (placeholders)
* - VIBE004: Orphaned exports (unused exports)
*/
class VibeScanner extends base_scanner_1.BaseScanner {
name = 'Vibe Code Scanner';
packageDeps = new Set();
exports = new Map();
imports = new Set();
async scan(options) {
const results = [];
this.initCache(options, 'vibe:1');
// Load package.json dependencies
this.loadPackageDeps(options.directory);
// First pass: collect exports and imports for cross-file analysis
await this.collectExportsAndImports(options);
// Second pass: run detection
for await (const ctx of this.iterateFiles(options, '**/*.{js,jsx,ts,tsx,svelte,astro}', ['node_modules/**', 'dist/**', 'build/**', '.next/**'])) {
if (this.hasFileSuppression(ctx.lines))
continue;
const cached = this.getCached(ctx.file, ctx.contentHash);
if (cached) {
results.push(...cached);
continue;
}
const fileResults = [];
// VIBE001: Hallucinated imports
fileResults.push(...this.detectHallucinatedImports(ctx.file, ctx.lines, options));
// VIBE002: Copy-paste artifacts
fileResults.push(...this.detectCopyPasteArtifacts(ctx.file, ctx.lines, options));
// VIBE003: Incomplete implementations (pattern-based)
fileResults.push(...this.detectIncompleteImplementations(ctx.file, ctx.lines, options));
this.setCached(ctx.file, ctx.contentHash, fileResults);
results.push(...fileResults);
}
// VIBE004: Orphaned exports (cross-file analysis)
results.push(...this.detectOrphanedExports(options));
this.saveCache();
return results;
}
loadPackageDeps(directory) {
this.packageDeps.clear();
const pkgPath = (0, path_1.join)(directory, 'package.json');
if (!(0, fs_1.existsSync)(pkgPath))
return;
try {
const pkg = JSON.parse((0, fs_1.readFileSync)(pkgPath, 'utf-8'));
const allDeps = {
...pkg.dependencies,
...pkg.devDependencies,
...pkg.peerDependencies
};
for (const dep of Object.keys(allDeps)) {
this.packageDeps.add(dep);
// Also add scoped package prefixes (e.g., @types/node -> @types)
if (dep.startsWith('@')) {
const scope = dep.split('/')[0];
this.packageDeps.add(scope);
}
}
// Add Node.js built-ins
const builtins = [
'fs', 'path', 'http', 'https', 'crypto', 'os', 'util', 'stream',
'events', 'buffer', 'url', 'querystring', 'child_process', 'cluster',
'dns', 'net', 'readline', 'repl', 'tls', 'dgram', 'vm', 'zlib',
'assert', 'async_hooks', 'console', 'constants', 'domain', 'inspector',
'module', 'perf_hooks', 'process', 'punycode', 'string_decoder',
'timers', 'trace_events', 'tty', 'v8', 'worker_threads'
];
builtins.forEach(b => {
this.packageDeps.add(b);
this.packageDeps.add(`node:${b}`);
});
}
catch { }
}
async collectExportsAndImports(options) {
this.exports.clear();
this.imports.clear();
for await (const ctx of this.iterateFiles(options, '**/*.{js,jsx,ts,tsx}', ['node_modules/**', 'dist/**', 'build/**', '.next/**'])) {
// Collect exports
ctx.lines.forEach((line, index) => {
// Named exports: export const/function/class Name
const namedExport = line.match(/export\s+(?:const|let|var|function|class|type|interface)\s+(\w+)/);
if (namedExport) {
const name = namedExport[1];
if (!this.exports.has(name))
this.exports.set(name, []);
this.exports.get(name).push({ file: ctx.file, line: index + 1, name });
}
// Export { name } or export { name as alias }
const exportBraces = line.match(/export\s*\{([^}]+)\}/);
if (exportBraces) {
const names = exportBraces[1].split(',').map(n => n.trim().split(/\s+as\s+/)[0].trim());
names.forEach(name => {
if (name && !this.exports.has(name))
this.exports.set(name, []);
if (name)
this.exports.get(name).push({ file: ctx.file, line: index + 1, name });
});
}
});
// Collect imports
ctx.lines.forEach(line => {
// import { Name } from 'module'
const importBraces = line.match(/import\s*\{([^}]+)\}\s*from/);
if (importBraces) {
const names = importBraces[1].split(',').map(n => n.trim().split(/\s+as\s+/)[0].trim());
names.forEach(name => name && this.imports.add(name));
}
// import Name from 'module'
const defaultImport = line.match(/import\s+(\w+)\s+from/);
if (defaultImport) {
this.imports.add(defaultImport[1]);
}
// import * as Name from 'module'
const namespaceImport = line.match(/import\s+\*\s+as\s+(\w+)\s+from/);
if (namespaceImport) {
this.imports.add(namespaceImport[1]);
}
});
}
}
detectHallucinatedImports(file, lines, options) {
const results = [];
const rule = (0, rules_1.getRule)('VIBE001');
if (!rule)
return results;
lines.forEach((line, index) => {
if (this.isSuppressed(lines, index, 'VIBE001'))
return;
// Match import statements: import ... from 'module' or require('module')
const importMatch = line.match(/(?:from\s+['"]([^'"]+)['"]|require\s*\(\s*['"]([^'"]+)['"]\s*\))/);
if (!importMatch)
return;
const moduleName = importMatch[1] || importMatch[2];
// Skip relative imports
if (moduleName.startsWith('.') || moduleName.startsWith('/'))
return;
// Skip if the specifier resolves via `tsconfig.json` paths/baseUrl.
// Covers the common `@/*` and `~/*` aliases used by Next.js, Vite,
// shadcn, etc. — without this check every aliased import was flagged
// as a hallucinated package.
if ((0, tsconfig_resolver_1.resolvesViaTsconfigPaths)(moduleName, options.directory))
return;
// Skip if it's a known dependency
const baseName = moduleName.startsWith('@')
? moduleName.split('/').slice(0, 2).join('/')
: moduleName.split('/')[0];
if (this.packageDeps.has(baseName))
return;
// Common false positives to skip — biased toward the v3 modern-JS scope
// (Next/React/Vite/SvelteKit/Astro/Remix/Hono/Lovable). Vue is intentionally
// omitted: ubon v3 dropped the Vue profile, and importing `vue` in a JS app
// we scan is a strong-enough signal that the user should still see it.
const commonModules = [
'react',
'react-dom',
'next',
'svelte',
'astro',
'hono',
'express',
'lodash',
'axios',
'zod'
];
if (commonModules.includes(baseName))
return;
// Common scoped namespaces that are routinely added without an exact
// dep entry (workspaces, peer-deps installed transitively, etc.).
const commonScopes = ['@sveltejs', '@remix-run', '@astrojs', '@hono', '@supabase', '@vercel'];
if (baseName.startsWith('@') && commonScopes.some((s) => baseName === s || baseName.startsWith(`${s}/`)))
return;
results.push(this.createResult({
type: 'error',
category: rule.meta.category,
message: `${rule.meta.message}: "${moduleName}"`,
file,
line: index + 1,
severity: rule.meta.severity,
ruleId: rule.meta.id,
confidence: 0.85,
confidenceReason: `Module "${baseName}" not found in package.json dependencies`,
fix: rule.meta.fix,
match: moduleName
}, line));
});
return results;
}
detectCopyPasteArtifacts(file, lines, _options) {
const results = [];
const rule = (0, rules_1.getRule)('VIBE002');
if (!rule)
return results;
// Look for repeated 5+ line blocks
const blockSize = 5;
const blocks = new Map();
for (let i = 0; i <= lines.length - blockSize; i++) {
const block = lines.slice(i, i + blockSize)
.map(l => l.trim())
.filter(l => l.length > 0 && !l.startsWith('//') && !l.startsWith('*'))
.join('\n');
if (block.length < 50)
continue; // Skip trivial blocks
if (!blocks.has(block)) {
blocks.set(block, []);
}
blocks.get(block).push(i + 1);
}
// Report blocks that appear 2+ times
const reported = new Set();
for (const [block, lineNumbers] of blocks) {
if (lineNumbers.length < 2)
continue;
// Only report the second occurrence onwards
for (let j = 1; j < lineNumbers.length; j++) {
const lineNum = lineNumbers[j];
if (reported.has(lineNum))
continue;
if (this.isSuppressed(lines, lineNum - 1, 'VIBE002'))
continue;
reported.add(lineNum);
results.push(this.createResult({
type: 'warning',
category: rule.meta.category,
message: `${rule.meta.message} (also at line ${lineNumbers[0]})`,
file,
line: lineNum,
severity: rule.meta.severity,
ruleId: rule.meta.id,
confidence: 0.7,
confidenceReason: `${blockSize}+ line block appears ${lineNumbers.length} times in file`,
fix: rule.meta.fix,
match: block.slice(0, 100)
}, lines[lineNum - 1]));
}
}
return results;
}
detectIncompleteImplementations(file, lines, _options) {
const results = [];
const rule = (0, rules_1.getRule)('VIBE003');
if (!rule || !rule.impl.patterns)
return results;
for (const pattern of rule.impl.patterns) {
lines.forEach((line, index) => {
if (line.trim().startsWith('//') && !pattern.pattern.source.includes('//'))
return;
if (this.isSuppressed(lines, index, 'VIBE003'))
return;
// Skip rule definition files
if (file.includes('/rules/') || file.includes('scanner'))
return;
const match = line.match(pattern.pattern);
if (match) {
results.push(this.createResult({
type: pattern.severity === 'high' ? 'error' : 'warning',
category: rule.meta.category,
message: pattern.message,
file,
line: index + 1,
severity: pattern.severity,
ruleId: pattern.ruleId,
confidence: pattern.confidence,
fix: pattern.fix,
match: match[0].slice(0, 100)
}, line));
}
});
}
return results;
}
detectOrphanedExports(_options) {
const results = [];
const rule = (0, rules_1.getRule)('VIBE004');
if (!rule)
return results;
// Skip common entry points and index files
const skipPatterns = [/index\.[jt]sx?$/, /page\.[jt]sx?$/, /layout\.[jt]sx?$/, /route\.[jt]sx?$/];
for (const [name, locations] of this.exports) {
// Skip if imported anywhere
if (this.imports.has(name))
continue;
// Skip common React/Next patterns
if (['default', 'getServerSideProps', 'getStaticProps', 'getStaticPaths', 'metadata', 'generateMetadata'].includes(name))
continue;
for (const loc of locations) {
// Skip index/entry files
if (skipPatterns.some(p => p.test(loc.file)))
continue;
results.push({
type: 'info',
category: rule.meta.category,
message: `${rule.meta.message}: "${name}"`,
file: loc.file,
line: loc.line,
severity: rule.meta.severity,
ruleId: rule.meta.id,
confidence: 0.6,
confidenceReason: `Export "${name}" not imported in any scanned file`,
fix: rule.meta.fix,
match: name
});
}
}
return results;
}
}
exports.VibeScanner = VibeScanner;
//# sourceMappingURL=vibe-scanner.js.map