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.
489 lines ⢠23.1 kB
JavaScript
;
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 __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const commander_1 = require("commander");
const package_json_1 = __importDefault(require("../package.json"));
const hooks_1 = require("./utils/hooks");
const init_1 = require("./utils/init");
const shared_1 = require("./cli/shared");
const program = new commander_1.Command();
program
.name('ubon')
.description('Ubon šŖ· ā peace of mind for AI-generated apps\n ⢠Try "ubon scan --interactive" for guided debugging\n ⢠Try "ubon mcp" to expose the scanner to your AI assistant')
.version(package_json_1.default.version);
// One-shot, best-effort update notifier. Runs after the command completes
// so it never blocks the user; fails silently in air-gapped environments.
function maybeNotifyUpdate() {
if (process.env.UBON_DISABLE_UPDATE_NOTIFIER === '1')
return;
if (process.env.CI || process.env.NODE_ENV === 'test')
return;
process.on('exit', () => {
try {
// Lazy-require so the dep stays optional and doesn't slow down startup.
const updateNotifier = require('update-notifier');
updateNotifier({ pkg: package_json_1.default, updateCheckInterval: 1000 * 60 * 60 * 24 }).notify({ defer: false });
}
catch {
/* ignore ā notifier is best-effort */
}
});
}
maybeNotifyUpdate();
program
.command('doctor')
.description('Diagnose the local Ubon environment (Node version, optional deps, git, etc.)')
.option('-d, --directory <path>', 'Project directory', process.cwd())
.action(async (opts) => {
const { runDoctor } = await Promise.resolve().then(() => __importStar(require('./cli/doctor')));
await runDoctor(opts.directory);
});
// Shared options for scan and check commands
const addCommonOptions = (cmd) => {
return cmd
.option('-d, --directory <path>', 'Directory to scan', process.cwd())
.option('-v, --verbose', 'Enable verbose output')
.option('--fail-on <level>', 'Fail on: none|warning|error', 'error')
.option('--min-confidence <number>', 'Minimum confidence threshold (0.0-1.0)')
.option('--enable-rule <id...>', 'Only enable these ruleIds (can be repeated)')
.option('--disable-rule <id...>', 'Disable these ruleIds (can be repeated)')
.option('--baseline <path>', 'Path to baseline file')
.option('--update-baseline', 'Update baseline with current findings and exit')
.option('--no-baseline', 'Do not apply baseline filtering')
.option('--json', 'Output results as JSON (for AI agents)')
.option('--sarif <path>', 'Write results as SARIF 2.1.0 to given path')
.option('--output <path>', 'Write JSON output to file')
.option('--changed-files <paths...>', 'Limit scanning to these files (relative paths)')
.option('--git-changed-since <ref>', 'Limit scanning to files changed since Git ref')
.option('--fix-dry-run', 'Compute and print auto-fix plan without writing files')
.option('--preview-fixes', 'Show diff-like preview of fixes without applying')
.option('--apply-fixes', 'Apply available safe auto-fixes to the codebase')
.option('--profile <name>', 'Scan profile: auto|lovable|react|next|sveltekit|astro|remix|hono', 'auto')
.option('--git-history-depth <n>', 'Scan last N commits for leaked secrets')
.option('--fast', 'Skip expensive checks (OSV, links) for faster results')
.option('--crawl-internal', '[deprecated v3] Crawl internal links with a headless browser (puppeteer)')
.option('--crawl-start-url <url>', 'Starting URL for internal crawl')
.option('--crawl-depth <n>', 'Max crawl depth', '2')
.option('--crawl-timeout <ms>', 'Per-page timeout in ms', '10000')
.option('--detailed', 'Show all findings including lower-confidence/noisy ones')
.option('--focus-critical', 'Only show critical (high severity) issues')
.option('--focus-security', 'Only show security issues (hide a11y/links/etc)')
.option('--focus-new', 'Only show issues not in baseline')
.option('--color <mode>', 'Colorize output: auto|always|never', 'auto')
.option('--group-by <mode>', 'Group results by: category|file|rule|severity', 'category')
.option('--format <mode>', 'Output format in human mode: human|table', 'human')
.option('--min-severity <level>', 'Minimum severity to show: low|medium|high')
.option('--max-issues <number>', 'Limit output to N most critical issues')
.option('--show-context', 'Show code context around findings (3-5 lines)')
.option('--explain', 'Show "why it matters" explanations for findings')
.option('--show-confidence', 'Show per-finding confidence score in human output')
.option('--show-suppressed', 'Include suppressed results in output')
.option('--ignore-suppressed', 'Completely ignore suppressed results (default: hide but count)')
.option('--clear-cache', 'Clear OSV vulnerability cache before scanning')
.option('--no-cache', 'Disable OSV caching for this scan')
.option('--no-result-cache', 'Disable per-file result caching')
.option('--pr-comment', 'Output a Markdown summary suitable for PR comments')
.option('--interactive', 'Walk through issues interactively with explanations and fix options')
.option('--ndjson', 'Output one JSON-encoded finding per line (streaming-friendly)')
.option('--quiet', 'Suppress banners, suggestions, and contextual guidance (CI-friendly)')
.option('--allow-config-js', 'Permit loading ubon.config.js (executes user-supplied code)')
.option('--preset <name>', 'Apply opinionated defaults: agent|ci|release|local')
.option('--schema', 'Print the JSON Schema for --json output and exit');
};
// Scan command
const scanCmd = program
.command('scan')
.description('Full scan with link checking (try --interactive for guided debugging)')
.option('-p, --port <number>', 'Development server port for link checking', '3000')
.option('--skip-build', 'Skip link checking (only run static analysis)')
.option('--base-sha <ref>', 'In CI, compare against this base ref and only fail on new issues')
.option('--create-pr', 'After applying fixes, create a PR (uses gh if available)')
.option('--watch', 'Watch files and re-run on changes (fast mode recommended)')
.option('--ai-friendly', 'Optimize output for AI consumption (json + context + explain + grouping + cap)');
addCommonOptions(scanCmd).action(async (options) => {
await (0, shared_1.runScanCommand)(options);
});
// Check command
const checkCmd = program
.command('check')
.description('Quick health check (static analysis only, try --ai-friendly for AI assistants)');
addCommonOptions(checkCmd).action(async (options) => {
await (0, shared_1.runCheckCommand)(options);
});
const changedCmd = program
.command('changed')
.description('Scan files changed since a Git ref (default: origin/main)');
addCommonOptions(changedCmd)
.option('--since <ref>', 'Git ref to compare against', 'origin/main')
.action(async (options) => {
await (0, shared_1.runCheckCommand)({
...options,
gitChangedSince: options.since || options.gitChangedSince || 'origin/main',
preset: options.preset || 'local'
});
});
const verifyCmd = program
.command('verify')
.description('Deterministic Ubon gate for CI, pre-commit, and release checks');
addCommonOptions(verifyCmd).action(async (options) => {
await (0, shared_1.runCheckCommand)({
...options,
preset: options.preset || 'ci'
});
});
const reviewCmd = program
.command('review')
.description('Produce a PR-ready Markdown review summary');
addCommonOptions(reviewCmd)
.option('--since <ref>', 'Git ref to compare against', 'origin/main')
.action(async (options) => {
await (0, shared_1.runCheckCommand)({
...options,
gitChangedSince: options.since || options.gitChangedSince || 'origin/main',
prComment: true,
failOn: options.failOn || 'none',
preset: options.preset || 'local'
});
});
const rulesCmd = program.command('rules').description('Inspect the Ubon rule catalog');
rulesCmd
.command('list')
.description('List rules for humans or agents')
.option('--json', 'Output the rule catalog as deterministic JSON', false)
.option('--category <name>', 'Filter by category')
.option('--severity <level>', 'Filter by severity: low|medium|high')
.action(async (options) => {
const { RULES } = await Promise.resolve().then(() => __importStar(require('./rules')));
const rules = Object.values(RULES)
.filter((rule) => !options.category || rule.category === options.category)
.filter((rule) => !options.severity || rule.severity === options.severity)
.sort((a, b) => a.id.localeCompare(b.id))
.map((rule) => ({
category: rule.category,
fix: rule.fix,
id: rule.id,
impact: rule.impact,
message: rule.message,
severity: rule.severity
}));
if (options.json) {
const stable = JSON.stringify(rules, (_key, value) => {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return Object.keys(value)
.sort()
.reduce((acc, key) => {
acc[key] = value[key];
return acc;
}, {});
}
return value;
}, 2);
process.stdout.write(`${stable}\n`);
return;
}
for (const rule of rules) {
console.log(`${rule.id.padEnd(8)} ${rule.severity.padEnd(6)} ${rule.category.padEnd(14)} ${rule.message}`);
}
});
program
.command('install-hooks')
.description('Install git pre-commit hooks for Ubon scanning')
.option('--mode <type>', 'Hook mode: fast|full', 'fast')
.option('--fail-on <level>', 'Fail on: error|warning', 'error')
.option('-d, --directory <path>', 'Project directory', process.cwd())
.option('--dry-run', 'Print the pre-commit config without writing files', false)
.option('--no-install', 'Write .pre-commit-config.yaml but do not run pre-commit install')
.action(async (options) => {
try {
(0, hooks_1.installPreCommitHooks)({
mode: options.mode,
failOn: options.failOn,
directory: options.directory,
dryRun: !!options.dryRun,
install: options.install
});
}
catch (e) {
console.error('ā Failed to install hooks:', e?.message || e);
process.exit(1);
}
});
program
.command('init')
.description('Generate ubon.config.json with project-specific settings')
.option('--profile <type>', 'Override auto-detected profile')
.option('--interactive', 'Interactive configuration')
.action(async (options) => {
try {
await (0, init_1.initializeConfig)({ profile: options.profile, interactive: !!options.interactive });
}
catch (e) {
console.error('ā Init failed:', e?.message || e);
process.exit(1);
}
});
program
.command('cache')
.description('Manage Ubon cache')
.option('--clear', 'Clear all cached data')
.option('--cleanup', 'Remove expired cache entries')
.option('--info', 'Show cache information')
.action(async (options) => {
const { FileCache } = await Promise.resolve().then(() => __importStar(require('./utils/cache')));
const osvCache = new FileCache('osv');
if (options.clear) {
osvCache.clear();
console.log('š§¹ Cache cleared successfully');
}
else if (options.cleanup) {
osvCache.cleanup();
console.log('š§¹ Expired cache entries removed');
}
else if (options.info) {
const { join } = await Promise.resolve().then(() => __importStar(require('path')));
const { homedir } = await Promise.resolve().then(() => __importStar(require('os')));
const cacheDir = join(homedir(), '.ubon', 'cache');
console.log(`š Cache directory: ${cacheDir}`);
console.log('š” Use --clear to remove all cached data');
console.log('š” Use --cleanup to remove expired entries');
}
else {
console.log('Use --clear, --cleanup, or --info');
}
});
program
.command('guide')
.description('[deprecated in v3] Print pointer to the docs site')
.action(async () => {
// The interactive `guide` command shipped a lot of duplicated copy
// and the rotating "tip of the day" suggestion. v3 collapses both
// into a single pointer; the canonical content lives in docs/ and on
// the README so we don't have three sources of truth.
console.error('šŖ· `ubon guide` is deprecated and will be removed in v3.1.');
console.error(' See: https://github.com/luisfer/ubon#readme');
console.error(' Or: https://github.com/luisfer/ubon/tree/main/docs');
});
program
.command('lsp')
.description('Start the Ubon language server')
.action(async () => {
const { startServer } = await Promise.resolve().then(() => __importStar(require('./lsp/server')));
startServer();
});
const hooksCmd = program.command('hooks').description('Manage Ubon editor hook integrations');
hooksCmd
.command('install')
.description('Install a Cursor hooks template that runs Ubon on every file edit and prompt')
.option('--cursor', 'Install Cursor hooks (.cursor/hooks.json + scripts) ā default', true)
.option('-d, --directory <path>', 'Project directory', process.cwd())
.option('--force', 'Overwrite existing files instead of merging', false)
.action(async (opts) => {
if (!opts.cursor) {
console.error('šŖ· Only --cursor hooks are supported right now.');
process.exit(1);
}
const { installCursorHooks } = await Promise.resolve().then(() => __importStar(require('./cli/hooks')));
const { wrote, skipped } = installCursorHooks({
directory: opts.directory,
cursor: true,
force: !!opts.force
});
for (const file of wrote)
console.log('šŖ· wrote ', file);
for (const file of skipped)
console.log('šŖ· skipped ', file, '(exists; use --force to overwrite)');
console.log('\nšŖ· Cursor will pick up hooks.json automatically. Restart Cursor if not.');
});
const agentCmd = program.command('agent').description('Install and diagnose Ubon agent harness integrations');
agentCmd
.command('install')
.description('Plan or write Cursor/Codex/Claude/pre-commit/GitHub harness files (dry-run by default)')
.option('-d, --directory <path>', 'Project directory', process.cwd())
.option('--cursor', 'Include Cursor hooks and rules')
.option('--claude', 'Include Claude Code guidance')
.option('--codex', 'Include AGENTS.md guidance')
.option('--pre-commit', 'Include .pre-commit-config.yaml')
.option('--github', 'Include GitHub Actions workflow')
.option('--all', 'Include every supported harness target')
.option('--write', 'Write files to disk (default is dry-run)', false)
.option('--force', 'Overwrite existing files instead of skipping', false)
.action(async (opts) => {
const { installAgentHarness } = await Promise.resolve().then(() => __importStar(require('./cli/agent')));
const result = installAgentHarness({
directory: opts.directory,
cursor: !!opts.cursor,
claude: !!opts.claude,
codex: !!opts.codex,
preCommit: !!opts.preCommit,
github: !!opts.github,
all: !!opts.all,
write: !!opts.write,
force: !!opts.force
});
if (!opts.write) {
console.log('šŖ· Ubon agent harness dry-run. Use --write to create files.');
for (const file of result.planned)
console.log('šŖ· would write ', file);
return;
}
for (const file of result.wrote)
console.log('šŖ· wrote ', file);
for (const file of result.skipped)
console.log('šŖ· skipped ', file, '(exists; use --force to overwrite)');
});
agentCmd
.command('doctor')
.description('Check whether this repo is ready for Ubon agent harnessing')
.option('-d, --directory <path>', 'Project directory', process.cwd())
.action(async (opts) => {
const { runDoctor } = await Promise.resolve().then(() => __importStar(require('./cli/doctor')));
await runDoctor(opts.directory, { agentHarness: true });
});
program
.command('mcp')
.description('Start the Ubon MCP (Model Context Protocol) server over stdio')
.action(async () => {
const { startMcpServer } = await Promise.resolve().then(() => __importStar(require('./mcp/server')));
await startMcpServer();
});
program
.command('completion <shell>')
.description('Print shell completion script (bash | zsh | fish)')
.action(async (shell) => {
const { emit } = await Promise.resolve().then(() => __importStar(require('./cli/completion')));
const { ok, output } = emit(shell);
if (ok) {
process.stdout.write(output);
}
else {
process.stderr.write(output);
process.exit(1);
}
});
program
.command('explain <ruleId>')
.description('Show detailed information about a rule')
.action(async (ruleId) => {
const { getRule, RULES } = await Promise.resolve().then(() => __importStar(require('./rules')));
const { default: chalk } = await Promise.resolve().then(() => __importStar(require('./utils/colors')));
const rule = getRule(ruleId.toUpperCase());
const legacyRule = RULES[ruleId.toUpperCase()];
if (!rule && !legacyRule) {
console.error(chalk.red(`ā Rule "${ruleId}" not found`));
console.log('');
console.log('š” Available rule prefixes:');
const ids = Object.keys(RULES);
const buckets = new Map();
for (const id of ids) {
const m = id.match(/^([A-Z]+)\d+/);
const prefix = m ? m[1] : id;
const arr = buckets.get(prefix) || [];
arr.push(id);
buckets.set(prefix, arr);
}
const sorted = Array.from(buckets.entries()).sort((a, b) => a[0].localeCompare(b[0]));
const pad = sorted.reduce((max, [p]) => Math.max(max, p.length), 0);
const numericTail = (id) => parseInt(id.replace(/^[A-Z]+/, ''), 10) || 0;
for (const [prefix, list] of sorted) {
const nums = list.map(numericTail).filter(n => n > 0).sort((a, b) => a - b);
const range = nums.length === 0
? `(${list.length} rules)`
: nums.length === 1 ? `${prefix}${String(nums[0]).padStart(3, '0')}`
: `${prefix}${String(nums[0]).padStart(3, '0')}-${String(nums[nums.length - 1]).padStart(3, '0')} (${list.length})`;
console.log(` ${prefix.padEnd(pad)} ${range}`);
}
process.exit(1);
}
const meta = rule?.meta || legacyRule;
const impl = rule?.impl;
console.log('');
console.log(chalk.bold.cyan(`šŖ· ${meta.id}: ${meta.message}`));
console.log('');
console.log(chalk.bold('Category:'), meta.category);
console.log(chalk.bold('Severity:'), meta.severity === 'high' ? chalk.red(meta.severity) : meta.severity === 'medium' ? chalk.yellow(meta.severity) : chalk.gray(meta.severity));
console.log(chalk.bold('Fix:'), meta.fix);
if (meta.impact) {
console.log(chalk.bold('Impact:'), meta.impact);
}
if (meta.helpUri) {
console.log(chalk.bold('Documentation:'), chalk.underline(meta.helpUri));
}
// Confidence scale explanation
console.log('');
console.log(chalk.bold('Confidence Scale:'));
console.log(' 0.9-1.0 Very high - AST-confirmed or exact pattern match');
console.log(' 0.8-0.9 High - Strong pattern match with context');
console.log(' 0.7-0.8 Medium - Pattern match, may need verification');
console.log(' 0.5-0.7 Lower - Heuristic detection, review recommended');
// Show patterns if available
if (impl?.patterns && impl.patterns.length > 0) {
console.log('');
console.log(chalk.bold('Detection Patterns:'));
impl.patterns.forEach((p, i) => {
console.log(` ${i + 1}. ${p.pattern.toString().slice(0, 60)}...`);
});
}
// Examples
console.log('');
console.log(chalk.bold('Example triggers:'));
const examples = {
'SEC001': ['const key = "sk-abc123xyz"', 'const stripe = "pk_test_..."'],
'SEC003': ['const token = "eyJhbGciOiJIUzI1..."'],
'SEC006': ['const password = "secret123"'],
'SEC016': ['eval(userInput)'],
'SEC017': ['<div dangerouslySetInnerHTML={{__html: content}} />'],
'A11Y001': ['<img src="photo.jpg" /> // missing alt'],
'VIBE001': ['import { foo } from "non-existent-package"'],
'VIBE003': ['throw new Error("Not implemented")'],
'VIBE004': ['export function unusedHelper() {} // never imported']
};
const ruleExamples = examples[meta.id.toUpperCase()];
if (ruleExamples) {
ruleExamples.forEach(ex => console.log(chalk.gray(` ${ex}`)));
}
else {
console.log(chalk.gray(' (No examples available)'));
}
console.log('');
console.log(chalk.bold('Suppression:'));
console.log(chalk.gray(` // ubon-disable-next-line ${meta.id}`));
console.log(chalk.gray(` // ubon-disable-file`));
});
program.parse();
//# sourceMappingURL=cli.js.map