UNPKG

ubon

Version:

Security scanner for AI-generated React/Next.js and Python apps. Catches hardcoded secrets, accessibility issues, and vulnerabilities that traditional linters miss.

550 lines (549 loc) โ€ข 27.5 kB
#!/usr/bin/env node "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 __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 index_1 = require("./index"); const package_json_1 = __importDefault(require("../package.json")); const sarif_1 = require("./utils/sarif"); const git_1 = require("./utils/git"); const config_1 = require("./utils/config"); const fix_1 = require("./utils/fix"); const hooks_1 = require("./utils/hooks"); const init_1 = require("./utils/init"); function renderPrMarkdown(results) { const groups = { high: [], medium: [], low: [] }; results.forEach(r => { (groups[r.severity] = groups[r.severity] || []).push(r); }); const lines = []; const counts = { high: groups.high?.length || 0, medium: groups.medium?.length || 0, low: groups.low?.length || 0 }; lines.push(`# Ubon Findings`); lines.push(`- High: ${counts.high} โ€ข Medium: ${counts.medium} โ€ข Low: ${counts.low}`); const order = ['high', 'medium', 'low']; order.forEach(sev => { const arr = groups[sev] || []; if (arr.length === 0) return; const title = sev === 'high' ? 'High' : sev === 'medium' ? 'Medium' : 'Low'; lines.push(`\n## ${title}`); arr.forEach((r) => { const loc = r.file ? `${r.file}${r.line ? `:${r.line}` : ''}` : ''; const conf = (r.confidence ?? 0).toFixed(2); const fix = r.fix ? ` โ€” ${r.fix}` : ''; lines.push(`- [${title}] ${r.ruleId}: ${r.message}${loc ? ` (${loc})` : ''} (confidence: ${conf})${fix}`); }); }); return lines.join('\n'); } function redact(value) { if (!value) return value; if (/sk-[A-Za-z0-9_-]{8,}/.test(value)) return value.replace(/sk-[A-Za-z0-9_-]{8,}/g, 'sk-********'); if (/eyJ[A-Za-z0-9._-]{20,}/.test(value)) return value.replace(/eyJ[A-Za-z0-9._-]{20,}/g, 'eyJ********'); return value; } function generateRecommendations(results) { const recommendations = []; const categories = results.reduce((acc, result) => { acc[result.category] = (acc[result.category] || 0) + 1; return acc; }, {}); if (categories.security > 0) { recommendations.push('Review and secure all API keys, passwords, and sensitive data'); recommendations.push('Remove or properly guard console.log statements before production'); } if (categories.accessibility > 0) { recommendations.push('Add alt attributes to all images for screen readers'); recommendations.push('Ensure all form inputs have proper labels'); recommendations.push('Replace clickable divs with semantic button elements'); } if (categories.links > 0) { recommendations.push('Test all navigation links and fix broken routes'); recommendations.push('Verify all image assets exist and are accessible'); } const errorCount = results.filter(r => r.type === 'error').length; if (errorCount > 0) { recommendations.unshift(`๐Ÿšจ ${errorCount} critical issues require immediate attention`); } return recommendations; } const program = new commander_1.Command(); program .name('ubon') .description('Ubon ๐Ÿชท โ€” peace of mind for vibe-coded apps') .version(package_json_1.default.version); program .command('scan') .description('Scan your React/Next.js application for issues') .option('-d, --directory <path>', 'Directory to scan', process.cwd()) .option('-p, --port <number>', 'Development server port for link checking', '3000') .option('--skip-build', 'Skip link checking (only run static analysis)') .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('--base-sha <ref>', 'In CI, compare against this base ref and only fail on new issues') .option('--fix-dry-run', 'Compute and print auto-fix plan without writing files') .option('--apply-fixes', 'Apply available safe auto-fixes to the codebase') .option('--create-pr', 'After applying fixes, create a PR (uses gh if available)') .option('--profile <name>', 'Scan profile: auto|react|next|python', 'auto') .option('--git-history-depth <n>', 'Scan last N commits for leaked secrets') .option('--fast', 'Skip expensive checks (OSV, links) for faster results') .option('--watch', 'Watch files and re-run on changes (fast mode recommended)') .option('--crawl-internal', 'Crawl internal links with a headless browser') .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('--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('--ai-friendly', 'Optimize output for AI consumption (json + context + explain + grouping + cap)') .option('--pr-comment', 'Output a Markdown summary suitable for PR comments') .action(async (options) => { const scanner = new index_1.UbonScan(options.verbose, options.json, options.color); const config = (0, config_1.loadConfig)(options.directory); const cliOptions = { directory: options.directory, port: options.port ? parseInt(options.port) : undefined, skipBuild: options.skipBuild, verbose: options.verbose, minConfidence: options.minConfidence ? parseFloat(options.minConfidence) : undefined, enabledRules: options.enableRule, disabledRules: options.disableRule, baselinePath: options.baseline, updateBaseline: options.updateBaseline, useBaseline: options.baseline !== false, changedFiles: options.changedFiles, gitChangedSince: options.gitChangedSince, profile: options.profile, gitHistoryDepth: options.gitHistoryDepth ? parseInt(options.gitHistoryDepth) : undefined, fast: !!options.fast, crawlInternal: !!options.crawlInternal, crawlStartUrl: options.crawlStartUrl, crawlDepth: options.crawlDepth ? parseInt(options.crawlDepth) : undefined, crawlTimeoutMs: options.crawlTimeout ? parseInt(options.crawlTimeout) : undefined, detailed: !!options.detailed, focusCritical: !!options.focusCritical, focusSecurity: !!options.focusSecurity, focusNew: !!options.focusNew, color: options.color, groupBy: options.groupBy, minSeverity: options.minSeverity, maxIssues: options.maxIssues ? parseInt(options.maxIssues) : undefined, showContext: !!options.showContext, explain: !!options.explain, showConfidence: !!options.showConfidence, showSuppressed: !!options.showSuppressed, ignoreSuppressed: !!options.ignoreSuppressed, clearCache: !!options.clearCache, noCache: !!options.noCache }; const scanOptions = (0, config_1.mergeOptions)(config, cliOptions); // AI-friendly preset: by default apply human-friendly settings; when flag is present, also force JSON const applyAiFriendly = (forceJson) => { if (forceJson) scanOptions.json = true; if (typeof scanOptions.showContext === 'undefined') scanOptions.showContext = true; if (typeof scanOptions.explain === 'undefined') scanOptions.explain = true; if (typeof scanOptions.groupBy === 'undefined') scanOptions.groupBy = 'severity'; if (!scanOptions.maxIssues) scanOptions.maxIssues = 15; }; if (options.aiFriendly) { applyAiFriendly(true); } else { // default human preset applyAiFriendly(false); } try { if (scanOptions.gitChangedSince && (!scanOptions.changedFiles || scanOptions.changedFiles.length === 0)) { scanOptions.changedFiles = (0, git_1.getChangedFilesSince)(scanOptions.gitChangedSince, scanOptions.directory); } const runOnce = async () => await scanner.diagnose(scanOptions); let results = await runOnce(); if (options.prComment) { const md = renderPrMarkdown(results); console.log(md); } else if (options.json) { // JSON output for AI agents const payload = { schemaVersion: '1.0.0', toolVersion: package_json_1.default.version, summary: { total: results.length, errors: results.filter(r => r.type === 'error').length, warnings: results.filter(r => r.type === 'warning').length, info: results.filter(r => r.type === 'info').length }, issues: results.map(r => ({ ...r, match: redact(r.match) })), recommendations: generateRecommendations(results) }; if (options.output) { const fs = await Promise.resolve().then(() => __importStar(require('fs'))); fs.writeFileSync(options.output, JSON.stringify(payload, null, 2)); } else { console.log(JSON.stringify(payload, null, 2)); } } else { // Human-readable output scanner.printResults(results, scanOptions); } if (options.sarif) { const sarif = (0, sarif_1.toSarif)(results, options.directory); const fs = await Promise.resolve().then(() => __importStar(require('fs'))); fs.writeFileSync(options.sarif, JSON.stringify(sarif, null, 2)); console.log(`SARIF report written to ${options.sarif}`); } if (options.fixDryRun || options.applyFixes) { const dryRun = !!options.fixDryRun && !options.applyFixes; const { changedFiles, appliedEditCount } = (0, fix_1.applyFixes)(results, options.directory, dryRun); if (dryRun) { console.log(JSON.stringify({ fixPlan: { files: changedFiles, edits: appliedEditCount } }, null, 2)); } else { console.log(JSON.stringify({ fixesApplied: { files: changedFiles, edits: appliedEditCount } }, null, 2)); if (options.createPr && appliedEditCount > 0 && (0, git_1.ensureGitRepo)(options.directory)) { const branchName = `ubon/fixes-${Date.now()}`; const title = `chore(ubon): apply safe autofixes (${appliedEditCount} edits)`; const body = `Automated safe fixes applied by Ubon.\n\nFiles changed: ${changedFiles.length}`; const pushRes = (0, git_1.createBranchCommitPush)({ cwd: options.directory, baseBranch: 'main', featureBranch: branchName, title, body }); if (pushRes.pushed) { const prRes = (0, git_1.tryOpenPullRequest)(options.directory, 'main', branchName, title, body); if (prRes.created) { console.log('โœ… Pull request created'); } else if (prRes.url) { console.log(`โžก๏ธ Open PR: ${prRes.url}`); } } } } } // Exit codes based on fail-on (applies to both human and JSON modes) { let considered = results; // CI gate: new issues only vs base SHA using fingerprints if (options.baseSha) { const changed = (0, git_1.getChangedFilesSince)(options.baseSha, options.directory); if (Array.isArray(changed) && changed.length > 0) { const changedSet = new Set(changed.map(p => p.replace(/^\.\//, ''))); considered = results.filter(r => r.file && changedSet.has(r.file)); } } const errorCount = considered.filter(r => r.type === 'error').length; const warningCount = considered.filter(r => r.type === 'warning').length; const failOn = (options.failOn || 'error'); const shouldFail = (failOn === 'error' && errorCount > 0) || (failOn === 'warning' && (errorCount + warningCount) > 0); if (shouldFail) process.exit(1); } if (options.watch) { const chokidar = await Promise.resolve().then(() => __importStar(require('chokidar'))); const watcher = chokidar.watch(['**/*.{js,jsx,ts,tsx,vue}'], { cwd: options.directory, ignored: ['node_modules/**', 'dist/**', 'build/**', '.next/**'] }); console.log('๐Ÿ‘€ Watching for changes...'); watcher.on('change', async () => { try { const t0 = Date.now(); results = await runOnce(); const dt = Date.now() - t0; if (options.json) { console.log(JSON.stringify({ summary: { total: results.length }, durationMs: dt }, null, 2)); } else { console.log(`๐Ÿชท Re-scan complete in ${dt}ms. Issues: ${results.length}`); } } catch { } }); } } catch (error) { if (options.json) { console.log(JSON.stringify({ error: error?.message || 'Unknown error' })); } else { console.error('โŒ Scan failed:', error); } process.exit(1); } }); program .command('check') .description('Quick health check (static analysis only)') .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('--apply-fixes', 'Apply available safe auto-fixes to the codebase') .option('--profile <name>', 'Scan profile: auto|react|next|python', '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', 'Crawl internal links with a headless browser') .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('--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('--pr-comment', 'Output a Markdown summary suitable for PR comments') .action(async (options) => { const scanner = new index_1.UbonScan(options.verbose, options.json, options.color); const config = (0, config_1.loadConfig)(options.directory); const cliOptions = { directory: options.directory, skipBuild: true, verbose: options.verbose, minConfidence: options.minConfidence ? parseFloat(options.minConfidence) : undefined, enabledRules: options.enableRule, disabledRules: options.disableRule, baselinePath: options.baseline, updateBaseline: options.updateBaseline, useBaseline: options.baseline !== false, changedFiles: options.changedFiles, gitChangedSince: options.gitChangedSince, profile: options.profile, gitHistoryDepth: options.gitHistoryDepth ? parseInt(options.gitHistoryDepth) : undefined, fast: !!options.fast, crawlInternal: !!options.crawlInternal, crawlStartUrl: options.crawlStartUrl, crawlDepth: options.crawlDepth ? parseInt(options.crawlDepth) : undefined, crawlTimeoutMs: options.crawlTimeout ? parseInt(options.crawlTimeout) : undefined, detailed: !!options.detailed, focusCritical: !!options.focusCritical, focusSecurity: !!options.focusSecurity, focusNew: !!options.focusNew, color: options.color, groupBy: options.groupBy, minSeverity: options.minSeverity, maxIssues: options.maxIssues ? parseInt(options.maxIssues) : undefined, showContext: !!options.showContext, explain: !!options.explain, showConfidence: !!options.showConfidence, showSuppressed: !!options.showSuppressed, ignoreSuppressed: !!options.ignoreSuppressed, clearCache: !!options.clearCache, noCache: !!options.noCache }; const scanOptions = (0, config_1.mergeOptions)(config, cliOptions); try { if (scanOptions.gitChangedSince && (!scanOptions.changedFiles || scanOptions.changedFiles.length === 0)) { scanOptions.changedFiles = (0, git_1.getChangedFilesSince)(scanOptions.gitChangedSince, scanOptions.directory); } const results = await scanner.diagnose(scanOptions); if (options.prComment) { const md = renderPrMarkdown(results); console.log(md); } else if (options.json) { // JSON output for AI agents const payload = { schemaVersion: '1.0.0', toolVersion: package_json_1.default.version, summary: { total: results.length, errors: results.filter(r => r.type === 'error').length, warnings: results.filter(r => r.type === 'warning').length, info: results.filter(r => r.type === 'info').length }, issues: results.map(r => ({ ...r, match: redact(r.match) })), recommendations: generateRecommendations(results) }; if (options.output) { const fs = await Promise.resolve().then(() => __importStar(require('fs'))); fs.writeFileSync(options.output, JSON.stringify(payload, null, 2)); } else { console.log(JSON.stringify(payload, null, 2)); } } else { // Human-readable output scanner.printResults(results, scanOptions); } if (options.sarif) { const sarif = (0, sarif_1.toSarif)(results, options.directory); const fs = await Promise.resolve().then(() => __importStar(require('fs'))); fs.writeFileSync(options.sarif, JSON.stringify(sarif, null, 2)); console.log(`SARIF report written to ${options.sarif}`); } if (options.fixDryRun || options.applyFixes) { const dryRun = !!options.fixDryRun && !options.applyFixes; const { changedFiles, appliedEditCount } = (0, fix_1.applyFixes)(results, options.directory, dryRun); if (dryRun) { console.log(JSON.stringify({ fixPlan: { files: changedFiles, edits: appliedEditCount } }, null, 2)); } else { console.log(JSON.stringify({ fixesApplied: { files: changedFiles, edits: appliedEditCount } }, null, 2)); } } { const errorCount = results.filter(r => r.type === 'error').length; const warningCount = results.filter(r => r.type === 'warning').length; const failOn = (options.failOn || 'error'); const shouldFail = (failOn === 'error' && errorCount > 0) || (failOn === 'warning' && (errorCount + warningCount) > 0); if (shouldFail) process.exit(1); } } catch (error) { if (options.json) { console.log(JSON.stringify({ error: error?.message || 'Unknown error' })); } else { console.error('โŒ Health check failed:', error); } process.exit(1); } }); 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') .action(async (options) => { try { (0, hooks_1.installPreCommitHooks)({ mode: options.mode, failOn: options.failOn }); } 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.parse();