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
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 });
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