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.
307 lines (277 loc) • 13.7 kB
JavaScript
"use strict";
/**
* `ubon hooks install` — drop a Cursor `hooks.json` template (and matching
* shell scripts) into the user's project. The hooks shell out to the
* locally-installed `ubon` binary so they work whether the user globally
* installed it (`npm i -g ubon`) or has it as a dev dep.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.installCursorHooks = installCursorHooks;
const fs_1 = require("fs");
const path_1 = require("path");
const HOOKS_JSON_TEMPLATE = {
version: 1,
hooks: {
afterFileEdit: [
{
command: '.cursor/hooks/ubon-after-edit.sh',
timeout: 30
}
],
beforeSubmitPrompt: [
{
command: '.cursor/hooks/ubon-secret-scan.sh',
timeout: 10,
failClosed: false
}
],
beforeShellExecution: [
{
command: '.cursor/hooks/ubon-before-shell.sh',
timeout: 10,
failClosed: false
}
],
afterShellExecution: [
{
command: '.cursor/hooks/ubon-after-shell.sh',
timeout: 15
}
],
beforeMCPExecution: [
{
command: '.cursor/hooks/ubon-before-mcp.sh',
timeout: 10,
failClosed: false
}
],
afterMCPExecution: [
{
command: '.cursor/hooks/ubon-after-mcp.sh',
timeout: 30
}
],
stop: [
{
command: '.cursor/hooks/ubon-stop-gate.sh',
timeout: 60
}
],
preCompact: [
{
command: '.cursor/hooks/ubon-precompact.sh',
timeout: 10
}
]
}
};
const AFTER_EDIT_SH = `#!/usr/bin/env bash
# Run a fast Ubon scan on the touched file after every Cursor edit. Surfaces
# secrets, prompt-injection sinks and AI-era issues right inside the editor.
set -euo pipefail
input="$(cat)"
file=$(printf '%s' "$input" | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{const j=JSON.parse(d);process.stdout.write(j.tool_input?.file_path||j.file_path||'')}catch{}});")
if [ -z "$file" ]; then
echo '{}'
exit 0
fi
# Use the project-local ubon if present, otherwise fall back to npx.
if [ -x ./node_modules/.bin/ubon ]; then
CMD="./node_modules/.bin/ubon"
else
CMD="npx --yes ubon"
fi
report=$($CMD check --json --changed-files "$file" 2>/dev/null || true)
if [ -z "$report" ]; then
echo '{}'
exit 0
fi
count=$(printf '%s' "$report" | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{const j=JSON.parse(d);process.stdout.write(String((j.issues||[]).length))}catch{process.stdout.write('0')}});")
if [ "$count" = "0" ]; then
echo '{}'
exit 0
fi
# Inject a small follow-up message so the agent sees the findings.
node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{const j=JSON.parse(d);const summary=(j.issues||[]).slice(0,5).map(i=>\`- [\${i.ruleId}] \${i.severity} \${i.message} (\${i.file}:\${i.line})\`).join('\\n');process.stdout.write(JSON.stringify({additional_context:'Ubon found '+(j.issues||[]).length+' issue(s) in the file you just edited:\\n'+summary}))}catch{process.stdout.write('{}')}});" <<< "$report"
`;
const SECRET_SCAN_SH = `#!/usr/bin/env bash
# Block prompt submission if it appears to contain a hardcoded secret. Uses
# Ubon's central redact patterns by piping through \`ubon check --json\` against
# a temp file. Cheap because we only scan a single tiny file.
set -euo pipefail
input="$(cat)"
prompt=$(printf '%s' "$input" | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{const j=JSON.parse(d);process.stdout.write(j.prompt||j.user_prompt||'')}catch{}});")
if [ -z "$prompt" ]; then
echo '{}'
exit 0
fi
tmp=$(mktemp)
printf '%s' "$prompt" > "$tmp.ts"
if [ -x ./node_modules/.bin/ubon ]; then
CMD="./node_modules/.bin/ubon"
else
CMD="npx --yes ubon"
fi
report=$($CMD check --json --changed-files "$tmp.ts" --enable-rule SEC001 SEC011 SEC014 AI001 AI004 2>/dev/null || true)
rm -f "$tmp.ts"
count=$(printf '%s' "$report" | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{const j=JSON.parse(d);process.stdout.write(String((j.issues||[]).length))}catch{process.stdout.write('0')}});")
if [ "$count" = "0" ]; then
echo '{}'
exit 0
fi
echo '{
"permission": "ask",
"user_message": "Your prompt looks like it may contain a hardcoded secret. Review before sending.",
"agent_message": "A hook flagged a possible secret in the prompt; ask the user before continuing."
}'
`;
const BEFORE_SHELL_SH = `#!/usr/bin/env bash
# Ask before shell commands that frequently turn an agent mistake into damage.
set -euo pipefail
input="$(cat)"
command=$(printf '%s' "$input" | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{const j=JSON.parse(d);process.stdout.write(j.command||j.tool_input?.command||'')}catch{}});")
if [ -z "$command" ]; then
echo '{}'
exit 0
fi
if printf '%s' "$command" | grep -Eiq '(curl|wget)[^|]*\\|[[:space:]]*(sh|bash|zsh)|rm[[:space:]]+-rf[[:space:]]+(/|\\$[A-Za-z_]|\\.)|git[[:space:]]+push[^\\n]*--force|npm[[:space:]]+publish|cat[[:space:]]+\\.env|printenv'; then
node -e "process.stdout.write(JSON.stringify({permission:'ask',user_message:'Ubon flagged this shell command as risky. Review it before allowing execution.',agent_message:'The shell command matched a Ubon risky-command pattern. Explain why it is needed and wait for user approval.'}))"
else
echo '{}'
fi
`;
const AFTER_SHELL_SH = `#!/usr/bin/env bash
# Surface obvious leaked secrets or failed verification commands after shell runs.
set -euo pipefail
input="$(cat)"
text=$(printf '%s' "$input" | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{const j=JSON.parse(d);process.stdout.write([j.stdout,j.stderr,j.output].filter(Boolean).join('\\n'))}catch{}});")
# ubon-disable-next-line SEC001 generated hook scans for secret-shaped output
if printf '%s' "$text" | grep -Eiq 'sk-(ant-|proj-)?[A-Za-z0-9_-]{20,}|github_pat_[A-Za-z0-9_]{20,}|AKIA[0-9A-Z]{16}'; then
node -e "process.stdout.write(JSON.stringify({additional_context:'Ubon hook: recent shell output appears to contain a secret-shaped value. Redact logs and rotate the credential if it was exposed.'}))"
elif printf '%s' "$text" | grep -Eiq '(tests? failed|typecheck failed|lint failed|npm ERR!|Command failed)'; then
node -e "process.stdout.write(JSON.stringify({additional_context:'Ubon hook: the last shell command appears to have failed verification. Fix the root cause before shipping.'}))"
else
echo '{}'
fi
`;
const BEFORE_MCP_SH = `#!/usr/bin/env bash
# Ask before MCP tools whose names imply writes, deploys, deletes, or publishing.
set -euo pipefail
input="$(cat)"
name=$(printf '%s' "$input" | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{const j=JSON.parse(d);process.stdout.write([j.server,j.tool,j.tool_name,j.name].filter(Boolean).join(':'))}catch{}});")
if printf '%s' "$name" | grep -Eiq '(delete|write|apply|mutate|deploy|publish|release|database|sql|drop|truncate)'; then
node -e "process.stdout.write(JSON.stringify({permission:'ask',user_message:'Ubon flagged this MCP tool as potentially mutating. Review before allowing it.',agent_message:'The MCP tool name implies a side effect. Explain intended changes and wait for approval.'}))"
else
echo '{}'
fi
`;
const AFTER_MCP_SH = `#!/usr/bin/env bash
# After MCP calls, scan changed files so tool side effects are visible to the agent.
set -euo pipefail
if [ -x ./node_modules/.bin/ubon ]; then
CMD="./node_modules/.bin/ubon"
else
CMD="npx --yes ubon"
fi
report=$($CMD check --json --fast --git-changed-since HEAD --fail-on none 2>/dev/null || true)
count=$(printf '%s' "$report" | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{const j=JSON.parse(d);process.stdout.write(String((j.issues||[]).length))}catch{process.stdout.write('0')}});")
if [ "$count" = "0" ]; then
echo '{}'
else
node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{const j=JSON.parse(d);const summary=(j.issues||[]).slice(0,5).map(i=>\`- [\${i.ruleId}] \${i.severity} \${i.message} (\${i.file}:\${i.line})\`).join('\\n');process.stdout.write(JSON.stringify({additional_context:'Ubon found '+(j.issues||[]).length+' issue(s) after the MCP call:\\n'+summary}))}catch{process.stdout.write('{}')}});" <<< "$report"
fi
`;
const STOP_GATE_SH = `#!/usr/bin/env bash
# Final gate: ask the agent to keep working if critical changed-file findings remain.
set -euo pipefail
if [ -x ./node_modules/.bin/ubon ]; then
CMD="./node_modules/.bin/ubon"
else
CMD="npx --yes ubon"
fi
report=$($CMD check --json --fast --focus-critical --git-changed-since HEAD --fail-on none 2>/dev/null || true)
count=$(printf '%s' "$report" | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{const j=JSON.parse(d);process.stdout.write(String((j.issues||[]).length))}catch{process.stdout.write('0')}});")
if [ "$count" = "0" ]; then
echo '{}'
else
node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{const j=JSON.parse(d);const summary=(j.issues||[]).slice(0,5).map(i=>\`- [\${i.ruleId}] \${i.message} (\${i.file}:\${i.line})\`).join('\\n');process.stdout.write(JSON.stringify({followup_message:'Ubon still reports '+(j.issues||[]).length+' critical changed-file issue(s). Fix these before stopping:\\n'+summary}))}catch{process.stdout.write('{}')}});" <<< "$report"
fi
`;
const PRECOMPACT_SH = `#!/usr/bin/env bash
# Preserve the Ubon verification state before the conversation compacts.
set -euo pipefail
if [ -x ./node_modules/.bin/ubon ]; then
CMD="./node_modules/.bin/ubon"
else
CMD="npx --yes ubon"
fi
summary=$($CMD check --json --fast --focus-critical --git-changed-since HEAD --fail-on none 2>/dev/null | node -e "let d='';process.stdin.on('data',c=>d+=c).on('end',()=>{try{const j=JSON.parse(d);process.stdout.write('Ubon critical changed-file findings: '+(j.issues||[]).length)}catch{process.stdout.write('Ubon status unavailable')}});" || true)
UBON_PRECOMPACT_SUMMARY="$summary" node -e "process.stdout.write(JSON.stringify({additional_context:process.env.UBON_PRECOMPACT_SUMMARY||'Ubon status unavailable'}))"
`;
function installCursorHooks(options) {
const dir = options.directory;
const cursorDir = (0, path_1.join)(dir, '.cursor');
const hooksDir = (0, path_1.join)(cursorDir, 'hooks');
const hooksJsonPath = (0, path_1.join)(cursorDir, 'hooks.json');
const afterEditPath = (0, path_1.join)(hooksDir, 'ubon-after-edit.sh');
const secretScanPath = (0, path_1.join)(hooksDir, 'ubon-secret-scan.sh');
const beforeShellPath = (0, path_1.join)(hooksDir, 'ubon-before-shell.sh');
const afterShellPath = (0, path_1.join)(hooksDir, 'ubon-after-shell.sh');
const beforeMcpPath = (0, path_1.join)(hooksDir, 'ubon-before-mcp.sh');
const afterMcpPath = (0, path_1.join)(hooksDir, 'ubon-after-mcp.sh');
const stopGatePath = (0, path_1.join)(hooksDir, 'ubon-stop-gate.sh');
const precompactPath = (0, path_1.join)(hooksDir, 'ubon-precompact.sh');
const wrote = [];
const skipped = [];
for (const dirPath of [cursorDir, hooksDir]) {
if (!(0, fs_1.existsSync)(dirPath))
(0, fs_1.mkdirSync)(dirPath, { recursive: true });
}
const writeIfMissing = (path, contents, mode) => {
if ((0, fs_1.existsSync)(path) && !options.force) {
skipped.push(path);
return;
}
(0, fs_1.mkdirSync)((0, path_1.dirname)(path), { recursive: true });
(0, fs_1.writeFileSync)(path, contents, 'utf-8');
if (mode !== undefined)
(0, fs_1.chmodSync)(path, mode);
wrote.push(path);
};
// Merge with any existing hooks.json instead of clobbering it.
if ((0, fs_1.existsSync)(hooksJsonPath) && !options.force) {
try {
const existing = JSON.parse((0, fs_1.readFileSync)(hooksJsonPath, 'utf-8'));
const merged = { ...existing, version: existing.version || 1 };
merged.hooks = merged.hooks || {};
for (const [event, hookList] of Object.entries(HOOKS_JSON_TEMPLATE.hooks)) {
const current = Array.isArray(merged.hooks[event]) ? merged.hooks[event] : [];
const cmds = new Set(current.map((h) => h?.command));
for (const hook of hookList) {
if (!cmds.has(hook.command))
current.push(hook);
}
merged.hooks[event] = current;
}
(0, fs_1.writeFileSync)(hooksJsonPath, JSON.stringify(merged, null, 2) + '\n', 'utf-8');
wrote.push(hooksJsonPath);
}
catch {
// Couldn't parse — leave the existing file alone.
skipped.push(hooksJsonPath);
}
}
else {
writeIfMissing(hooksJsonPath, JSON.stringify(HOOKS_JSON_TEMPLATE, null, 2) + '\n');
}
writeIfMissing(afterEditPath, AFTER_EDIT_SH, 0o755);
writeIfMissing(secretScanPath, SECRET_SCAN_SH, 0o755);
writeIfMissing(beforeShellPath, BEFORE_SHELL_SH, 0o755);
writeIfMissing(afterShellPath, AFTER_SHELL_SH, 0o755);
writeIfMissing(beforeMcpPath, BEFORE_MCP_SH, 0o755);
writeIfMissing(afterMcpPath, AFTER_MCP_SH, 0o755);
writeIfMissing(stopGatePath, STOP_GATE_SH, 0o755);
writeIfMissing(precompactPath, PRECOMPACT_SH, 0o755);
return { wrote, skipped };
}
//# sourceMappingURL=hooks.js.map