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.

448 lines 18 kB
"use strict"; /** * Ubon MCP server. * * Exposes the Ubon scanner over the Model Context Protocol so AI assistants * (Cursor, Claude Desktop, Windsurf, Cline, OpenAI Apps) can call: * * ubon.scan — run a full scan and return the v2.0.0 JSON report * ubon.check — alias for scan with skipBuild=true (cheap loop) * ubon.explain — return rule metadata + remediation hints * ubon.preview-fixes — return file-level diffs for auto-fixable findings * ubon.apply-fixes — write the auto-fixes to disk (gated by `--apply`) * * The `@modelcontextprotocol/sdk` is an *optional* dependency. We import it * dynamically and degrade gracefully if it isn't installed, so users who * never touch MCP don't carry the extra weight. */ 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; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.MCP_TEST_HANDLERS = void 0; exports.startMcpServer = startMcpServer; const fs_1 = require("fs"); const path_1 = require("path"); const __1 = require(".."); const rules_1 = require("../rules"); const fix_1 = require("../utils/fix"); const git_1 = require("../utils/git"); const issue_context_1 = require("../utils/issue-context"); function resolveDirectory(input) { return (0, path_1.resolve)(input || process.cwd()); } 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 json(payload) { return { content: [{ type: 'text', text: stableStringify(payload, 2) }] }; } function buildScanOptions(args) { const directory = resolveDirectory(args.directory); const changedFiles = args.changedFiles && args.changedFiles.length > 0 ? args.changedFiles : (args.gitChangedSince || args.baseSha) ? (0, git_1.getChangedFilesSince)((args.gitChangedSince || args.baseSha), directory) : undefined; return { directory, profile: args.profile || 'auto', fast: !!args.fast, skipBuild: true, minConfidence: typeof args.minConfidence === 'number' ? args.minConfidence : undefined, changedFiles, gitChangedSince: args.gitChangedSince || args.baseSha, enabledRules: args.enabledRules, disabledRules: args.disabledRules, baselinePath: args.baseline, focusNew: !!args.focusNew, focusSecurity: !!args.focusSecurity, focusCritical: !!args.focusCritical, showContext: !!args.showContext, explain: !!args.explain, quiet: true }; } async function runScan(args) { const scanner = new __1.UbonScan(false, true); const options = buildScanOptions(args); const results = await scanner.diagnose(options); const payload = { schemaVersion: '2.0.0', toolVersion: require('../../package.json').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((result) => ({ ...result, context: args.showContext ? (0, issue_context_1.buildIssueContext)(options.directory, result.file, result.line) : undefined })) }; return json(payload); } async function runExplain(args) { const id = args.ruleId; if (!id) { return { content: [{ type: 'text', text: stableStringify({ error: 'missing ruleId' }) }] }; } const rule = (0, rules_1.getRule)(id); if (!rule) { return { content: [ { type: 'text', text: stableStringify({ error: `unknown rule ${id}`, knownRules: Object.keys(rules_1.RULES).sort() }) } ] }; } return { content: [ { type: 'text', text: stableStringify({ id: rule.meta.id, category: rule.meta.category, severity: rule.meta.severity, message: rule.meta.message, fix: rule.meta.fix, impact: rule.meta.impact, helpUri: rule.meta.helpUri }) } ] }; } async function runPreviewFixes(args) { const scanner = new __1.UbonScan(false, true); const options = buildScanOptions(args); const results = await scanner.diagnose(options); const previews = (0, fix_1.previewFixes)(results, options.directory); return json({ previews }); } async function runApplyFixes(args) { const scanner = new __1.UbonScan(false, true); const options = buildScanOptions(args); const results = await scanner.diagnose(options); // `apply: false` keeps this tool safe by default — clients must opt in. const dryRun = args.apply !== true; const { changedFiles, appliedEditCount } = (0, fix_1.applyFixes)(results, options.directory, dryRun); return { content: [ { type: 'text', text: stableStringify({ dryRun, changedFiles, appliedEditCount }) } ] }; } async function runPlanFixes(args) { const scanner = new __1.UbonScan(false, true); const options = buildScanOptions({ ...args, fast: true }); const results = await scanner.diagnose(options); const steps = results .filter((result) => result.fix) .map((result) => ({ ruleId: result.ruleId, severity: result.severity, confidence: result.confidence, file: result.file, line: result.line, fix: result.fix, autofixable: !!result.fixEdits?.length })); return json({ steps }); } async function runStatus(args) { const directory = resolveDirectory(args.directory); return json({ directory, toolVersion: require('../../package.json').version, config: { json: (0, fs_1.existsSync)((0, path_1.join)(directory, 'ubon.config.json')), js: (0, fs_1.existsSync)((0, path_1.join)(directory, 'ubon.config.js')), packageJson: (0, fs_1.existsSync)((0, path_1.join)(directory, 'package.json')) }, harness: { cursorHooks: (0, fs_1.existsSync)((0, path_1.join)(directory, '.cursor', 'hooks.json')), cursorRule: (0, fs_1.existsSync)((0, path_1.join)(directory, '.cursor', 'rules', 'ubon.mdc')), agentsMd: (0, fs_1.existsSync)((0, path_1.join)(directory, 'AGENTS.md')), claudeMd: (0, fs_1.existsSync)((0, path_1.join)(directory, 'CLAUDE.md')), preCommit: (0, fs_1.existsSync)((0, path_1.join)(directory, '.pre-commit-config.yaml')), githubWorkflow: (0, fs_1.existsSync)((0, path_1.join)(directory, '.github', 'workflows', 'ubon.yml')), baseline: (0, fs_1.existsSync)((0, path_1.join)(directory, '.ubon.baseline.json')), cacheIgnored: (0, fs_1.existsSync)((0, path_1.join)(directory, '.gitignore')) && (0, fs_1.readFileSync)((0, path_1.join)(directory, '.gitignore'), 'utf-8').split(/\r?\n/).includes('.ubon/') }, ruleCount: Object.keys(rules_1.RULES).length }); } async function runRuleCatalog() { return json({ rules: Object.values(rules_1.RULES) .map((rule) => ({ id: rule.id, category: rule.category, severity: rule.severity, message: rule.message, fix: rule.fix, helpUri: rule.helpUri })) .sort((a, b) => a.id.localeCompare(b.id)) }); } async function runVerify(args) { const scanner = new __1.UbonScan(false, true); const options = buildScanOptions({ ...args, fast: true, focusCritical: args.focusCritical ?? true }); const results = await scanner.diagnose(options); const errors = results.filter((r) => r.type === 'error').length; const warnings = results.filter((r) => r.type === 'warning').length; const failOn = args.failOn || 'error'; const ok = failOn === 'none' || (failOn === 'error' ? errors === 0 : errors + warnings === 0); return json({ ok, failOn, summary: { total: results.length, errors, warnings, info: results.filter((r) => r.type === 'info').length }, issues: results }); } const TOOLS = [ { name: 'ubon.scan', description: 'Run a full Ubon scan on the given directory. Returns the v2.0.0 JSON report (use the schema at docs/schema/ubon-finding.schema.json).', inputSchema: { type: 'object', properties: { directory: { type: 'string', description: 'Absolute path to scan (defaults to CWD).' }, profile: { type: 'string', enum: ['auto', 'lovable', 'react', 'next', 'sveltekit', 'astro', 'remix', 'hono'] }, fast: { type: 'boolean', description: 'Skip OSV / link checks for a faster loop.' }, minConfidence: { type: 'number', minimum: 0, maximum: 1 }, changedFiles: { type: 'array', items: { type: 'string' } }, gitChangedSince: { type: 'string' }, baseSha: { type: 'string' }, enabledRules: { type: 'array', items: { type: 'string' } }, disabledRules: { type: 'array', items: { type: 'string' } }, baseline: { type: 'string' }, focusNew: { type: 'boolean' }, focusSecurity: { type: 'boolean' }, focusCritical: { type: 'boolean' }, showContext: { type: 'boolean' }, explain: { type: 'boolean' } } }, handler: runScan }, { name: 'ubon.check', description: 'Cheap static-analysis-only scan. Same shape as ubon.scan.', inputSchema: { type: 'object', properties: { directory: { type: 'string' }, profile: { type: 'string' }, minConfidence: { type: 'number', minimum: 0, maximum: 1 }, changedFiles: { type: 'array', items: { type: 'string' } }, gitChangedSince: { type: 'string' }, baseSha: { type: 'string' }, enabledRules: { type: 'array', items: { type: 'string' } }, disabledRules: { type: 'array', items: { type: 'string' } }, focusSecurity: { type: 'boolean' }, focusCritical: { type: 'boolean' }, showContext: { type: 'boolean' }, explain: { type: 'boolean' } } }, handler: (args) => runScan({ ...args, fast: true }) }, { name: 'ubon.explain', description: 'Return metadata for a rule (severity, fix, impact, helpUri).', inputSchema: { type: 'object', properties: { ruleId: { type: 'string' } }, required: ['ruleId'] }, handler: runExplain }, { name: 'ubon.preview-fixes', description: 'Return file-level diffs for auto-fixable findings without writing to disk.', inputSchema: { type: 'object', properties: { directory: { type: 'string' }, profile: { type: 'string' } } }, handler: runPreviewFixes }, { name: 'ubon.apply-fixes', description: 'Apply auto-fixes. Defaults to dry-run; pass `apply: true` to write changes to disk.', inputSchema: { type: 'object', properties: { directory: { type: 'string' }, profile: { type: 'string' }, apply: { type: 'boolean' } } }, handler: runApplyFixes }, { name: 'ubon.plan-fixes', description: 'Return ordered fix steps for current findings without writing to disk.', inputSchema: { type: 'object', properties: { directory: { type: 'string' }, profile: { type: 'string' }, changedFiles: { type: 'array', items: { type: 'string' } }, gitChangedSince: { type: 'string' }, baseSha: { type: 'string' } } }, handler: runPlanFixes }, { name: 'ubon.status', description: 'Return Ubon config and agent-harness status for the project.', inputSchema: { type: 'object', properties: { directory: { type: 'string' } } }, handler: runStatus }, { name: 'ubon.rule-catalog', description: 'Return the machine-readable rule catalog.', inputSchema: { type: 'object', properties: {} }, handler: runRuleCatalog }, { name: 'ubon.verify', description: 'Run a compact deterministic verification gate for agents and hooks.', inputSchema: { type: 'object', properties: { directory: { type: 'string' }, profile: { type: 'string' }, changedFiles: { type: 'array', items: { type: 'string' } }, gitChangedSince: { type: 'string' }, baseSha: { type: 'string' }, failOn: { type: 'string', enum: ['none', 'warning', 'error'] }, focusCritical: { type: 'boolean' } } }, handler: runVerify } ]; exports.MCP_TEST_HANDLERS = Object.fromEntries(TOOLS.map((tool) => [tool.name, tool.handler])); async function startMcpServer() { // Dynamic import to keep the SDK truly optional. let McpServer; let StdioServerTransport; try { const serverMod = await Promise.resolve().then(() => __importStar(require('@modelcontextprotocol/sdk/server/index.js'))); const stdioMod = await Promise.resolve().then(() => __importStar(require('@modelcontextprotocol/sdk/server/stdio.js'))); McpServer = serverMod.Server; StdioServerTransport = stdioMod.StdioServerTransport; } catch (err) { process.stderr.write('🪷 ubon mcp: the optional dependency `@modelcontextprotocol/sdk` is not installed.\n' + ' It ships as an optionalDependency of `ubon`, so this only happens if your\n' + ' install skipped optional deps (`npm install --no-optional`, container images,\n' + ' some lockfile tooling). Install it manually:\n' + ' npm install -g @modelcontextprotocol/sdk\n' + ' See docs/MCP.md for details.\n'); process.exit(1); } const server = new McpServer({ name: 'ubon', version: require('../../package.json').version }, { capabilities: { tools: {} } }); // Register tool list + dispatcher. The SDK exposes both `setRequestHandler` // and a higher-level helper depending on version; we use the request-handler // form for maximum portability across SDK minor versions. const typesMod = await Promise.resolve().then(() => __importStar(require('@modelcontextprotocol/sdk/types.js'))); server.setRequestHandler(typesMod.ListToolsRequestSchema, async () => ({ tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) })); server.setRequestHandler(typesMod.CallToolRequestSchema, async (request) => { const tool = TOOLS.find((t) => t.name === request.params.name); if (!tool) { return { content: [{ type: 'text', text: JSON.stringify({ error: `unknown tool ${request.params.name}` }) }], isError: true }; } try { return await tool.handler(request.params.arguments || {}); } catch (err) { return { content: [{ type: 'text', text: JSON.stringify({ error: err?.message || String(err) }) }], isError: true }; } }); const transport = new StdioServerTransport(); await server.connect(transport); process.stderr.write('🪷 ubon mcp: connected over stdio\n'); } //# sourceMappingURL=server.js.map