UNPKG

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.

478 lines 20.7 kB
"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 }); exports.redact = void 0; exports.renderPrMarkdown = renderPrMarkdown; exports.generateRecommendations = generateRecommendations; exports.applyPresetOptions = applyPresetOptions; exports.buildScanOptions = buildScanOptions; exports.applyAiFriendlyPreset = applyAiFriendlyPreset; exports.outputResults = outputResults; exports.handleFixes = handleFixes; exports.checkExitCondition = checkExitCondition; exports.runScanCommand = runScanCommand; exports.dumpSchema = dumpSchema; exports.runCheckCommand = runCheckCommand; const index_1 = require("../index"); 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 redact_1 = require("../utils/redact"); const issue_context_1 = require("../utils/issue-context"); const profiles_1 = require("../core/profiles"); const package_json_1 = __importDefault(require("../../package.json")); /** * Strip undefined fields and let `stableStringify` sort the rest. Keeping * undefined out of the payload prevents non-deterministic key churn (a key * present in some issues but absent in others would otherwise change the * sorted output's shape). */ function normaliseIssue(issue) { const out = {}; for (const [key, value] of Object.entries(issue)) { if (value === undefined) continue; out[key] = value; } return out; } /** * Deterministic JSON serialiser: sorts keys alphabetically at every level so * the output is byte-for-byte identical across runs. Required for CI diffs, * baseline files, and the upcoming MCP transport. */ function stableStringify(value, indent = 2) { const replacer = (_key, v) => { if (v && typeof v === 'object' && !Array.isArray(v)) { return Object.keys(v) .sort() .reduce((acc, k) => { acc[k] = v[k]; return acc; }, {}); } return v; }; return JSON.stringify(value, replacer, indent); } 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'); } // Re-export the centralized redactor so existing imports keep working. exports.redact = redact_1.redact; 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; } function applyPresetOptions(options) { const preset = options.preset; if (!preset) return; if (!['agent', 'ci', 'release', 'local'].includes(preset)) { throw new Error(`Unknown preset "${preset}". Expected agent|ci|release|local.`); } if (preset === 'agent') { options.json = true; options.quiet = true; options.fast = true; options.showContext = true; options.explain = true; options.groupBy = options.groupBy || 'severity'; options.maxIssues = options.maxIssues || '15'; } else if (preset === 'ci') { options.quiet = true; options.fast = true; options.failOn = options.failOn || 'error'; } else if (preset === 'release') { options.quiet = true; options.focusCritical = true; options.failOn = options.failOn || 'error'; } else if (preset === 'local') { options.showContext = true; options.explain = true; options.showConfidence = true; } } function buildScanOptions(options, defaults = {}) { const config = (0, config_1.loadConfig)(options.directory, { allowConfigJs: !!options.allowConfigJs }); const cliOptions = { directory: options.directory, port: options.port ? parseInt(options.port) : undefined, skipBuild: options.skipBuild ?? defaults.skipBuild, verbose: options.verbose, minConfidence: options.minConfidence ? parseFloat(options.minConfidence) : undefined, enabledRules: options.enableRule, disabledRules: options.disableRule, baselinePath: options.baseline === false ? undefined : options.baseline, updateBaseline: options.updateBaseline, useBaseline: options.baseline !== false, changedFiles: options.changedFiles, gitChangedSince: options.gitChangedSince, profile: (() => { const raw = options.profile; if (raw && Object.prototype.hasOwnProperty.call(profiles_1.REMOVED_PROFILES, raw)) { process.stderr.write(`🪷 ubon: profile "${raw}" was removed in v3.0.0. ` + `${profiles_1.REMOVED_PROFILES[raw]} See MIGRATION-v3.md.\n`); process.exit(2); } return raw; })(), gitHistoryDepth: options.gitHistoryDepth ? parseInt(options.gitHistoryDepth) : undefined, fast: !!options.fast, crawlInternal: (() => { if (options.crawlInternal) { process.stderr.write('🪷 `--crawl-internal` (puppeteer) is deprecated and will be removed in v3.1. ' + 'Use a dedicated link checker (e.g. lychee, linkinator) instead.\n'); } return !!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, format: options.format, 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, noResultCache: !!options.noResultCache, interactive: !!options.interactive, quiet: !!options.quiet, ndjson: !!options.ndjson, json: !!options.json, allowConfigJs: !!options.allowConfigJs }; return (0, config_1.mergeOptions)(config, cliOptions); } function applyAiFriendlyPreset(scanOptions, 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; } async function outputResults(scanner, results, scanOptions, options) { if (options.prComment) { const md = renderPrMarkdown(results); console.log(md); } else if (options.json || options.ndjson) { // Sort by severity → file → line → ruleId for byte-deterministic output. // Agents and CI diff tools rely on stable ordering. const sevOrder = { high: 0, medium: 1, low: 2 }; const sorted = [...results].sort((a, b) => (sevOrder[a.severity] - sevOrder[b.severity]) || (a.file || '').localeCompare(b.file || '') || ((a.line || 0) - (b.line || 0)) || a.ruleId.localeCompare(b.ruleId)); const issues = sorted.map(r => normaliseIssue({ ...r, context: scanOptions.showContext ? (0, issue_context_1.buildIssueContext)(scanOptions.directory, r.file, r.line) : undefined, match: (0, exports.redact)(r.match) })); if (options.ndjson) { // Each finding must serialise on a single line so consumers can // splitOnLine and JSON.parse incrementally. Pass indent=0 to stableStringify. const lines = issues.map(i => stableStringify(i, 0)).join('\n'); if (options.output) { const fs = await Promise.resolve().then(() => __importStar(require('fs'))); fs.writeFileSync(options.output, lines + '\n'); } else { process.stdout.write(lines + '\n'); } } else { const payload = { schemaVersion: '2.0.0', toolVersion: package_json_1.default.version, summary: { total: sorted.length, errors: sorted.filter(r => r.type === 'error').length, warnings: sorted.filter(r => r.type === 'warning').length, info: sorted.filter(r => r.type === 'info').length }, issues, recommendations: generateRecommendations(sorted) }; const serialised = stableStringify(payload, 2); if (options.output) { const fs = await Promise.resolve().then(() => __importStar(require('fs'))); fs.writeFileSync(options.output, serialised + '\n'); } else { process.stdout.write(serialised + '\n'); } } } else { await 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}`); } } async function handleFixes(results, options) { // Handle preview-fixes first (read-only) if (options.previewFixes) { const previews = (0, fix_1.previewFixes)(results, options.directory); (0, fix_1.printFixPreviews)(previews); return; } if (!options.fixDryRun && !options.applyFixes) return; 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}`); } } } } } function checkExitCondition(results, options) { let considered = options.showSuppressed ? results : results.filter((r) => !r.suppressed); 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'; return (failOn === 'error' && errorCount > 0) || (failOn === 'warning' && (errorCount + warningCount) > 0); } async function runScanCommand(options, defaults = {}) { applyPresetOptions(options); if (options.schema) { if (await dumpSchema()) return; } // --ndjson and --json both require stdout to contain only the JSON payload. // Force quiet mode in that case so progress chatter doesn't corrupt parsing. const effectiveQuiet = options.quiet || options.ndjson || options.json; const scanner = new index_1.UbonScan(options.verbose, options.json, options.color, effectiveQuiet); const scanOptions = buildScanOptions(options, defaults); if (effectiveQuiet) scanOptions.quiet = true; if (options.aiFriendly) { applyAiFriendlyPreset(scanOptions, true); } else { applyAiFriendlyPreset(scanOptions, 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(); await outputResults(scanner, results, scanOptions, options); await handleFixes(results, options); if (checkExitCondition(results, options)) { process.exit(1); } if (options.watch) { const chokidar = await Promise.resolve().then(() => __importStar(require('chokidar'))); const watcher = chokidar.watch(['**/*.{js,jsx,ts,tsx,svelte,astro}'], { cwd: options.directory, ignored: ['node_modules/**', 'dist/**', 'build/**', '.next/**'] }); console.log('👀 Watching for changes...'); let debounceTimer = null; let isScanning = false; watcher.on('change', async () => { if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(async () => { if (isScanning) return; isScanning = true; 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 { } isScanning = false; }, 300); }); } } catch (error) { if (options.json) { console.log(JSON.stringify({ error: error?.message || 'Unknown error' })); } else { console.error('❌ Scan failed:', error); } process.exit(1); } } async function dumpSchema() { const path = await Promise.resolve().then(() => __importStar(require('path'))); const fs = await Promise.resolve().then(() => __importStar(require('fs'))); const schemaPath = path.join(__dirname, '..', '..', 'docs', 'schema', 'ubon-finding.schema.json'); if (fs.existsSync(schemaPath)) { process.stdout.write(fs.readFileSync(schemaPath, 'utf8')); return true; } return false; } async function runCheckCommand(options) { applyPresetOptions(options); if (options.schema) { if (await dumpSchema()) return; } const effectiveQuiet = options.quiet || options.ndjson || options.json; const scanner = new index_1.UbonScan(options.verbose, options.json, options.color, effectiveQuiet); const scanOptions = buildScanOptions(options, { skipBuild: true }); if (effectiveQuiet) scanOptions.quiet = true; 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); await outputResults(scanner, results, scanOptions, options); await handleFixes(results, options); if (checkExitCondition(results, options)) { 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); } } //# sourceMappingURL=shared.js.map