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.
179 lines • 8.54 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.EnvScanner = void 0;
const glob_1 = require("glob");
const fs_1 = require("fs");
class EnvScanner {
name = 'Environment Variables Scanner';
envPatterns = [
{
ruleId: 'ENV002',
pattern: /^(?!#).*=.*(?:sk-|pk_live_|pk_test_|rk_live_|rk_test_|eyJ)[a-zA-Z0-9_-]+$/gm,
message: 'Potential API key in .env file',
severity: 'high',
fix: 'Ensure this .env file is in .gitignore and not committed'
},
{
ruleId: 'ENV003',
pattern: /^(?!#).*PASSWORD.*=.+$/gmi,
message: 'Password stored in .env file',
severity: 'high',
fix: 'Ensure .env files are never committed to version control'
},
{
ruleId: 'ENV004',
pattern: /^(?!#).*SECRET.*=.+$/gmi,
message: 'Secret value in .env file',
severity: 'high',
fix: 'Verify .env is in .gitignore and use .env.example for documentation'
},
{
// Values in NEXT_PUBLIC_* / VITE_* / PUBLIC_* / EXPO_PUBLIC_* are baked
// into the browser bundle. A database/Redis/Mongo connection string in
// one of these is an immediate credential leak.
ruleId: 'ENV008',
pattern: /^(?!#)\s*(?:NEXT_PUBLIC_|VITE_|PUBLIC_|EXPO_PUBLIC_)[A-Z0-9_]*(?:URL|URI|DSN|HOST|CONN|CONNECTION|ENDPOINT|DATABASE)[A-Z0-9_]*\s*=\s*.*(?:postgres|postgresql|mongodb|redis|mysql|amqp|clickhouse|mssql|rediss):\/\/.+$/gmi,
message: 'Client-exposed env var carries a database/service connection URL (leaks to browser bundle)',
severity: 'high',
fix: 'Rename without the `NEXT_PUBLIC_`/`VITE_`/`PUBLIC_` prefix so the value stays server-only; rotate the credential if it has already shipped.',
// ubon-disable-next-line SEC018 documented detector wording, not a credential
confidenceReason: 'NEXT_PUBLIC_/VITE_/PUBLIC_ env with a postgres://, mongodb://, redis://… value ships to the client bundle.'
}
];
async scan(options) {
const results = [];
// Check for .env files
const envFiles = await (0, glob_1.glob)('.env*', {
cwd: options.directory,
ignore: ['node_modules/**', '.env.example', '.env.template']
});
// Check if .env files are properly ignored
const gitignorePath = `${options.directory}/.gitignore`;
const hasGitignore = (0, fs_1.existsSync)(gitignorePath);
let gitignoreContent = '';
if (hasGitignore) {
try {
gitignoreContent = (0, fs_1.readFileSync)(gitignorePath, 'utf-8');
}
catch (error) {
// Skip if can't read gitignore
}
}
for (const file of envFiles) {
// Skip example files
if (file.includes('.example') || file.includes('.template')) {
continue;
}
try {
const content = (0, fs_1.readFileSync)(`${options.directory}/${file}`, 'utf-8');
const lines = content.split('\n');
// Check if this .env file is in gitignore
const isIgnored = gitignoreContent.includes('.env') ||
gitignoreContent.includes(file) ||
gitignoreContent.includes('.env*');
if (!isIgnored && file !== '.env.local') {
results.push({
type: 'error',
category: 'security',
message: `.env file "${file}" may not be in .gitignore`,
file,
ruleId: 'ENV001',
confidence: 0.9,
confidenceReason: '.env file found but not listed in .gitignore',
severity: 'high',
fix: 'Add .env files to .gitignore to prevent accidental commits'
});
}
// Scan for secrets in env files
lines.forEach((line, index) => {
this.envPatterns.forEach(({ ruleId, pattern, message, severity, fix, confidenceReason }) => {
const m = line.match(pattern);
if (m) {
results.push({
type: 'warning',
category: 'security',
message: `${message} in ${file}`,
file,
line: index + 1,
range: { startLine: index + 1, startColumn: 1, endLine: index + 1, endColumn: Math.max(1, line.length) },
ruleId,
confidence: 0.85,
confidenceReason: confidenceReason || 'Pattern matches known secret format in .env file',
match: m[0]?.slice(0, 200),
severity,
fix
});
}
});
});
// Check for real Supabase URLs/keys in .env files
if (content.includes('supabase.co') || content.includes('eyJ')) {
results.push({
type: 'warning',
category: 'security',
message: `Supabase credentials in ${file}`,
file,
ruleId: 'ENV005',
confidence: 0.8,
confidenceReason: 'File contains supabase.co URL or JWT-like token (eyJ...)',
severity: 'medium',
fix: 'Ensure this .env file is not committed to version control'
});
}
}
catch (error) {
// Skip files that can't be read
}
}
// Check for missing .env.example
const hasEnvExample = (0, fs_1.existsSync)(`${options.directory}/.env.example`);
const hasEnvFile = envFiles.length > 0;
if (hasEnvFile && !hasEnvExample) {
results.push({
type: 'warning',
category: 'security',
message: 'Missing .env.example file for documentation',
ruleId: 'ENV006',
confidence: 0.7,
severity: 'low',
fix: 'Create .env.example with placeholder values for team setup'
});
}
// Drift: keys present in .env but not in .env.example (and vice versa)
if (hasEnvFile && hasEnvExample) {
try {
const example = (0, fs_1.readFileSync)(`${options.directory}/.env.example`, 'utf-8');
const exampleKeys = new Set(example.split('\n').map(l => l.trim()).filter(l => l && !l.startsWith('#')).map(l => l.split('=')[0]));
const actualKeys = new Set();
for (const file of envFiles) {
try {
const c = (0, fs_1.readFileSync)(`${options.directory}/${file}`, 'utf-8');
c.split('\n').forEach(l => {
const t = l.trim();
if (t && !t.startsWith('#'))
actualKeys.add(t.split('=')[0]);
});
}
catch { }
}
const missingInExample = Array.from(actualKeys).filter(k => !exampleKeys.has(k));
const missingInEnv = Array.from(exampleKeys).filter(k => !actualKeys.has(k));
if (missingInExample.length || missingInEnv.length) {
results.push({
type: 'warning',
category: 'security',
message: 'Environment variable drift between .env and .env.example',
ruleId: 'ENV007',
confidence: 0.7,
severity: 'low',
fix: 'Align keys across .env and .env.example'
});
}
}
catch { }
}
return results;
}
}
exports.EnvScanner = EnvScanner;
//# sourceMappingURL=env-scanner.js.map