ubon
Version:
Security scanner for AI-generated React/Next.js and Python apps. Catches hardcoded secrets, accessibility issues, and vulnerabilities that traditional linters miss.
465 lines (464 loc) âĸ 23.1 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.EnvScanner = exports.AccessibilityScanner = exports.LinkScanner = exports.SecurityScanner = exports.UbonScan = void 0;
const crypto_1 = require("crypto");
const fs_1 = require("fs");
const path_1 = require("path");
const security_scanner_1 = require("./scanners/security-scanner");
const link_scanner_1 = require("./scanners/link-scanner");
const accessibility_scanner_1 = require("./scanners/accessibility-scanner");
const env_scanner_1 = require("./scanners/env-scanner");
const python_security_scanner_1 = require("./scanners/python-security-scanner");
const osv_scanner_1 = require("./scanners/osv-scanner");
const git_history_scanner_1 = require("./scanners/git-history-scanner");
const ast_security_scanner_1 = require("./scanners/ast-security-scanner");
const internal_crawler_1 = require("./scanners/internal-crawler");
const iac_scanner_1 = require("./scanners/iac-scanner");
const rails_security_scanner_1 = require("./scanners/rails-security-scanner");
const glob_1 = require("glob");
const logger_1 = require("./utils/logger");
const chalk_1 = __importDefault(require("chalk"));
const rules_1 = require("./types/rules");
const suppressions_1 = require("./utils/suppressions");
class UbonScan {
constructor(verbose = false, silent = false, colorMode = 'auto') {
this.scanners = [];
this.linkScanner = new link_scanner_1.LinkScanner();
this.logger = new logger_1.Logger(verbose, silent, colorMode);
this.useColor = this.shouldUseColor(colorMode);
}
shouldUseColor(mode) {
if (mode === 'always')
return true;
if (mode === 'never')
return false;
return process.stdout.isTTY && !process.env.NO_COLOR;
}
colorize(fn, text) {
return this.useColor ? fn(text) : text;
}
brand(text) {
return this.useColor ? chalk_1.default.hex('#c99cb3')(text) : text;
}
async diagnose(options) {
this.logger.title('Starting Ubon');
// Auto-detect profile if needed (Python if .py files present)
let profile = options.profile || 'auto';
if (profile === 'auto') {
const py = await (0, glob_1.glob)('**/*.py', { cwd: options.directory, ignore: ['.venv/**', 'venv/**', 'node_modules/**', 'dist/**', 'build/**', '.next/**', 'examples/**'] });
if (py.length > 0)
profile = 'python';
}
// Select scanners based on profile
this.scanners = this.resolveScanners(profile, options.fast);
// Runtime, non-persistent defaults for human-friendly noise reduction
if (!options.json && options.profile !== 'python') {
// If user didn't set minConfidence, prefer a gentle default
if (typeof options.minConfidence !== 'number') {
options.minConfidence = 0.8;
}
}
const allResults = [];
// Run static file scanners
for (const scanner of this.scanners) {
this.logger.info(`Running ${scanner.name}...`);
try {
const results = await scanner.scan(options);
allResults.push(...results);
this.logger.success(`${scanner.name} completed (${results.length} issues found)`);
}
catch (error) {
this.logger.error(`${scanner.name} failed: ${error}`);
}
}
// Fast mode skips link and OSV scanners
if (!options.fast) {
this.logger.info(`Running ${this.linkScanner.name}...`);
try {
const linkResults = await this.linkScanner.scan(options);
allResults.push(...linkResults);
this.logger.success(`${this.linkScanner.name} completed (${linkResults.length} issues found)`);
}
catch (error) {
this.logger.error(`${this.linkScanner.name} failed: ${error}`);
}
}
else {
this.logger.info('⥠Fast mode: Skipping external link checks');
}
// Internal crawler (opt-in)
if (options.crawlInternal && !options.fast) {
const crawler = new internal_crawler_1.InternalCrawler();
this.logger.info(`Running ${crawler.name}...`);
try {
const cres = await crawler.scan(options);
allResults.push(...cres);
this.logger.success(`${crawler.name} completed (${cres.length} issues found)`);
}
catch (e) {
this.logger.error(`${crawler.name} failed: ${e}`);
}
}
// Git history scanner if enabled
if (options.gitHistoryDepth && options.gitHistoryDepth > 0) {
const hist = new git_history_scanner_1.GitHistoryScanner();
this.logger.info(`Running ${hist.name}...`);
try {
const hres = await hist.scan(options);
allResults.push(...hres);
this.logger.success(`${hist.name} completed (${hres.length} issues found)`);
}
catch (e) {
this.logger.error(`${hist.name} failed: ${e}`);
}
}
const filtered = this.filterResults(allResults, options);
const withFingerprints = filtered.map(r => ({ ...r, fingerprint: this.computeFingerprint(r) }));
const withSuppressions = (0, suppressions_1.applySuppressions)(withFingerprints);
const afterBaseline = await this.applyBaseline(withSuppressions, options);
const finalResults = this.applyFocusFilters(afterBaseline, options);
return this.sortResults(finalResults);
}
resolveScanners(profile, fast) {
const p = profile || 'auto';
if (p === 'python') {
const arr = [new python_security_scanner_1.PythonSecurityScanner(), new env_scanner_1.EnvScanner()];
if (!fast)
arr.push(new osv_scanner_1.OSVScanner());
return arr;
}
if (p === 'rails') {
const arr = [new rails_security_scanner_1.RailsSecurityScanner()];
return arr;
}
// vue/react/next fall through to JS scanners
// auto/react/next default to JS scanners
const jsArr = [new security_scanner_1.SecurityScanner(), new ast_security_scanner_1.AstSecurityScanner(), new accessibility_scanner_1.AccessibilityScanner(), new env_scanner_1.EnvScanner(), new iac_scanner_1.IacScanner()];
if (!fast)
jsArr.push(new osv_scanner_1.OSVScanner());
return jsArr;
}
printResults(results, options) {
// Separate suppressed from active results
const allResultsWithSuppressions = results;
const suppressedCount = results.filter(r => r.suppressed).length;
// Apply suppression filtering
const activeResults = (0, suppressions_1.filterSuppressedResults)(results, {
showSuppressed: options?.showSuppressed,
ignoreSuppressed: options?.ignoreSuppressed
});
if (activeResults.length === 0 && suppressedCount === 0) {
this.logger.success('No issues found! Your app is looking healthy! đ');
return;
}
if (activeResults.length === 0 && suppressedCount > 0) {
this.logger.success(`No active issues found! ${suppressedCount} issues suppressed. đ`);
return;
}
// Apply filters and limits
let filteredResults = this.applyResultFilters(activeResults, options);
// Smart suggestion for overwhelm
const totalActive = activeResults.length;
if (!options?.maxIssues && totalActive > 50) {
this.logger.info(this.colorize(chalk_1.default.gray, `Found ${totalActive} issues. Tip: use --max-issues 10 to focus on critical items first.`));
}
// Severity-first header
const errorCount = filteredResults.filter(r => r.type === 'error').length;
const warnCount = filteredResults.filter(r => r.type === 'warning').length;
const criticalCount = filteredResults.filter(r => r.severity === 'high').length;
const highText = criticalCount > 0 ? this.colorize(chalk_1.default.bgRed.white, ` ${criticalCount} CRITICAL `) : '';
const suppressedText = suppressedCount > 0 ? this.colorize(chalk_1.default.gray, ` ${suppressedCount} suppressed`) : '';
console.log(`\n${this.brand('đǎ')} ${this.colorize(chalk_1.default.bold, 'Triage')}: ${highText} ${this.colorize(chalk_1.default.red, errorCount + ' errors')}, ${this.colorize(chalk_1.default.yellow, warnCount + ' warnings')}${suppressedText}`);
if (filteredResults.length !== activeResults.length) {
console.log(`${this.colorize(chalk_1.default.gray, ` (showing ${filteredResults.length} of ${activeResults.length} active issues)`)}`);
}
this.logger.separator();
this.logger.title(`Found ${filteredResults.length} issues:`);
const groupedResults = this.groupResults(filteredResults, options?.groupBy || 'severity');
Object.entries(groupedResults).forEach(([groupKey, groupResults]) => {
const icon = this.getGroupIcon(groupKey, options?.groupBy || 'category');
const lotus = this.brand(icon);
const count = this.colorize(chalk_1.default.gray, `(${groupResults.length})`);
console.log(`\n${lotus} ${this.colorize(chalk_1.default.bold, groupKey.toUpperCase())} ${count}:`);
groupResults.forEach(result => {
const isError = result.type === 'error';
const icon = isError ? this.colorize(chalk_1.default.red, 'â') : this.colorize(chalk_1.default.yellow, 'â');
const location = result.file ? this.colorize(chalk_1.default.gray, ` (${result.file}:${result.line})`) : '';
const sev = (result.severity || '').toLowerCase();
const badge = sev === 'high'
? this.colorize(chalk_1.default.bgRed.white, ' HIGH ')
: sev === 'medium'
? this.colorize(chalk_1.default.bgYellow.black, ' MED ')
: sev === 'low'
? this.colorize(chalk_1.default.bgBlue.white, ' LOW ')
: '';
const rule = result.ruleId ? this.colorize(chalk_1.default.gray, ` {${result.ruleId}}`) : '';
const msgColor = isError ? chalk_1.default.red : chalk_1.default.yellow;
const suppressedIndicator = result.suppressed ? this.colorize(chalk_1.default.gray, ' [SUPPRESSED]') : '';
const confText = (options?.showConfidence || options?.verbose) ? this.colorize(chalk_1.default.gray, ` (confidence: ${(result.confidence ?? 0).toFixed(2)})`) : '';
console.log(` ${icon} ${badge} ${this.colorize(msgColor, result.message)}${location}${rule}${confText}${suppressedIndicator}`);
if (result.fix) {
console.log(` ${this.brand('đǎ')} ${this.colorize(chalk_1.default.green, result.fix)}`);
}
// Show suppression reason if available
if (result.suppressed && result.suppressionReason) {
console.log(` ${this.colorize(chalk_1.default.gray, 'đ')} ${this.colorize(chalk_1.default.italic, 'Suppressed: ' + result.suppressionReason)}`);
}
// Show "why it matters" explanation if enabled
if (options?.explain && result.ruleId) {
const ruleMeta = rules_1.RULES[result.ruleId];
if (ruleMeta?.impact) {
console.log(` ${this.colorize(chalk_1.default.blue, 'đĄ')} ${this.colorize(chalk_1.default.italic, ruleMeta.impact)}`);
}
}
// Show code context if enabled and available
if (options?.showContext && result.file && result.line) {
const context = this.getCodeContext(result.file, result.line);
if (context) {
console.log(` ${this.colorize(chalk_1.default.gray, 'ââ Code context:')}`);
context.forEach((line, idx) => {
const lineNum = (result.line - 2 + idx).toString().padStart(3);
const isTarget = idx === 2; // middle line (0-indexed)
const marker = isTarget ? this.colorize(chalk_1.default.red, 'âē') : this.colorize(chalk_1.default.gray, ' ');
const lineColor = isTarget ? chalk_1.default.yellow : chalk_1.default.gray;
console.log(` ${this.colorize(chalk_1.default.gray, 'â')} ${marker} ${this.colorize(lineColor, lineNum)} ${this.colorize(lineColor, line)}`);
});
console.log(` ${this.colorize(chalk_1.default.gray, 'ââ')}`);
}
}
});
});
this.logger.separator();
this.printSummary(results);
}
groupByCategory(results) {
return results.reduce((acc, result) => {
if (!acc[result.category]) {
acc[result.category] = [];
}
acc[result.category].push(result);
return acc;
}, {});
}
applyResultFilters(results, options) {
let filtered = [...results];
// Apply severity filter
if (options?.minSeverity) {
const severityOrder = { low: 1, medium: 2, high: 3 };
const minLevel = severityOrder[options.minSeverity];
filtered = filtered.filter(r => severityOrder[r.severity] >= minLevel);
}
// Sort by priority: severity (high->low), then type (error->warning->info)
filtered.sort((a, b) => {
const severityOrder = { high: 3, medium: 2, low: 1 };
const typeOrder = { error: 3, warning: 2, info: 1 };
const sevDiff = severityOrder[b.severity] - severityOrder[a.severity];
if (sevDiff !== 0)
return sevDiff;
return typeOrder[b.type] - typeOrder[a.type];
});
// Apply max issues limit
if (options?.maxIssues && options.maxIssues > 0) {
filtered = filtered.slice(0, options.maxIssues);
}
return filtered;
}
groupResults(results, groupBy) {
return results.reduce((acc, result) => {
let key;
switch (groupBy) {
case 'file':
key = result.file || 'unknown';
break;
case 'rule':
key = result.ruleId || 'unknown';
break;
case 'severity':
key = result.severity || 'unknown';
break;
case 'category':
default:
key = result.category;
break;
}
if (!acc[key]) {
acc[key] = [];
}
acc[key].push(result);
return acc;
}, {});
}
getCategoryIcon(category) {
const icons = {
security: 'đ',
links: 'đ',
accessibility: 'âŋ',
performance: 'âĄ',
seo: 'đ'
};
return icons[category] || 'đ';
}
getGroupIcon(groupKey, groupBy) {
switch (groupBy) {
case 'category':
return this.getCategoryIcon(groupKey);
case 'file':
return 'đ';
case 'rule':
return 'âī¸';
case 'severity':
const severityIcons = {
high: 'đ¨',
medium: 'â ī¸',
low: 'âšī¸'
};
return severityIcons[groupKey] || 'đ';
default:
return 'đ';
}
}
getCodeContext(filePath, lineNumber) {
try {
const content = (0, fs_1.readFileSync)(filePath, 'utf8');
const lines = content.split('\n');
// Get 2 lines before and 2 lines after the target line (5 total)
const startLine = Math.max(0, lineNumber - 3); // -3 because lineNumber is 1-indexed
const endLine = Math.min(lines.length - 1, lineNumber + 1); // +1 for 2 lines after
const contextLines = [];
for (let i = startLine; i <= endLine; i++) {
contextLines.push(lines[i] || '');
}
return contextLines.length > 0 ? contextLines : null;
}
catch (error) {
// File might not exist or be readable
return null;
}
}
printSummary(results) {
const errors = results.filter(r => r.type === 'error').length;
const warnings = results.filter(r => r.type === 'warning').length;
console.log(`\n${this.brand('đǎ')} ${this.colorize(chalk_1.default.bold, 'Summary')}: ${this.colorize(chalk_1.default.red, errors + ' errors')}, ${this.colorize(chalk_1.default.yellow, warnings + ' warnings')}`);
if (errors > 0) {
this.logger.error('Critical issues found that should be fixed immediately');
}
else {
this.logger.success('No critical issues found');
}
}
filterResults(results, options) {
let filtered = results;
if (options.changedFiles && options.changedFiles.length > 0) {
const set = new Set(options.changedFiles.map(f => f.replace(/^[./]+/, '')));
filtered = filtered.filter(r => !r.file || set.has(r.file));
}
if (typeof options.minConfidence === 'number') {
filtered = filtered.filter(r => (r.confidence ?? 1) >= options.minConfidence);
}
if (options.enabledRules && options.enabledRules.length > 0) {
const set = new Set(options.enabledRules);
filtered = filtered.filter(r => set.has(r.ruleId));
}
if (options.disabledRules && options.disabledRules.length > 0) {
const set = new Set(options.disabledRules);
filtered = filtered.filter(r => !set.has(r.ruleId));
}
return filtered;
}
applyFocusFilters(results, options) {
let out = results;
if (options.focusNew) {
// already applied baseline; no-op here since baseline removed old issues
}
if (options.focusSecurity) {
out = out.filter(r => r.category === 'security');
}
if (options.focusCritical) {
out = out.filter(r => r.severity === 'high');
}
if (!options.detailed && typeof options.minConfidence !== 'number') {
// gentle noise reduction when not detailed: default minConfidence 0.8 for human runs
out = out.filter(r => (r.confidence ?? 1) >= 0.8);
}
return out;
}
computeFingerprint(result) {
const hash = (0, crypto_1.createHash)('sha256');
const normalizedPath = (result.file || '').replace(/\\/g, '/');
const snippet = (result.match || '').slice(0, 200);
hash.update([result.ruleId, normalizedPath, snippet].join('|'));
return hash.digest('hex').slice(0, 16);
}
sortResults(results) {
const severityRank = { error: 0, warning: 1, info: 2 };
return [...results].sort((a, b) => {
if (severityRank[a.type] !== severityRank[b.type])
return severityRank[a.type] - severityRank[b.type];
if (a.category !== b.category)
return a.category.localeCompare(b.category);
if ((a.file || '') !== (b.file || ''))
return (a.file || '').localeCompare(b.file || '');
if ((a.line || 0) !== (b.line || 0))
return (a.line || 0) - (b.line || 0);
return a.ruleId.localeCompare(b.ruleId);
});
}
async applyBaseline(results, options) {
const baselinePath = options.baselinePath || (0, path_1.join)(options.directory, '.ubon.baseline.json');
// Update baseline mode
if (options.updateBaseline) {
const fingerprints = Array.from(new Set(results.map(r => r.fingerprint))).sort();
const payload = { generatedAt: new Date().toISOString(), fingerprints };
try {
(0, fs_1.writeFileSync)(baselinePath, JSON.stringify(payload, null, 2));
this.logger.success(`Baseline updated at ${baselinePath}`);
}
catch (err) {
this.logger.error(`Failed to write baseline: ${err}`);
}
return [];
}
const useBaseline = options.useBaseline !== false; // default true
if (!useBaseline)
return results;
// Load baseline fingerprints if exists
if (!(0, fs_1.existsSync)(baselinePath))
return results;
try {
const content = (0, fs_1.readFileSync)(baselinePath, 'utf-8');
const data = JSON.parse(content);
const set = new Set(data.fingerprints || []);
return results.filter(r => !set.has(r.fingerprint));
}
catch {
return results;
}
}
}
exports.UbonScan = UbonScan;
__exportStar(require("./types"), exports);
var security_scanner_2 = require("./scanners/security-scanner");
Object.defineProperty(exports, "SecurityScanner", { enumerable: true, get: function () { return security_scanner_2.SecurityScanner; } });
var link_scanner_2 = require("./scanners/link-scanner");
Object.defineProperty(exports, "LinkScanner", { enumerable: true, get: function () { return link_scanner_2.LinkScanner; } });
var accessibility_scanner_2 = require("./scanners/accessibility-scanner");
Object.defineProperty(exports, "AccessibilityScanner", { enumerable: true, get: function () { return accessibility_scanner_2.AccessibilityScanner; } });
var env_scanner_2 = require("./scanners/env-scanner");
Object.defineProperty(exports, "EnvScanner", { enumerable: true, get: function () { return env_scanner_2.EnvScanner; } });