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.

377 lines 17.7 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.AgentSettingsScanner = void 0; const glob_1 = require("glob"); const fs_1 = require("fs"); const path_1 = require("path"); const rules_1 = require("../rules"); const redact_1 = require("../utils/redact"); const file_source_cache_1 = require("../utils/file-source-cache"); /** * Agent-tooling scanner — inspects the dotfiles that AI coding tools ship * alongside a project: `.claude/` (settings, hooks, agents, MCP), `.cursor/` * (rules, mcp.json), `.windsurf/`, `.aider*`, `CLAUDE.md`, `.cursorrules`, * `.windsurfrules`. * * Detections target the classes of mistake AI workflows produce routinely: * committed API keys, unquoted variable expansion in shell hooks, remote * piped-to-shell commands, prompt-injection markers in agent memory, and * raw secrets inside MCP server env blocks. */ class AgentSettingsScanner { name = 'Agent Settings Scanner'; async scan(options) { const results = []; const maxSize = options.maxFileSize || file_source_cache_1.DEFAULT_MAX_FILE_SIZE; const sourceCache = file_source_cache_1.FileSourceCache.forDirectory(options.directory); const patterns = [ '.claude/**/*.json', '.claude/**/*.md', '.claude/**/*.sh', '.claude/**/*.mjs', '.claude/**/*.js', '.cursor/**/*.json', '.cursor/**/*.mdc', '.cursor/**/*.sh', '.cursor/commands/**/*', '.cursor/skills/**/*', '.cursor/rules/*', '.cursorrules', '.cursor-rules', '.codex/**/*.toml', '.codex/**/*.md', '.windsurf/**/*.json', '.windsurfrules', '.aider.conf.yml', '.aider.conf.yaml', '.aiderconfig', '.agents/skills/**/*', '.continue/**/*.json', '.cline/**/*.json', 'cline_mcp_settings.json', '.mcp.json', 'mcp.json', 'CLAUDE.md', 'AGENTS.md', 'GEMINI.md' ]; const files = await (0, glob_1.glob)(patterns, { cwd: options.directory, dot: true, ignore: ['node_modules/**', 'dist/**', 'build/**', '.git/**'] }); for (const rel of files) { const absolute = (0, path_1.join)(options.directory, rel); let content; try { if ((0, fs_1.statSync)(absolute).size > maxSize) continue; content = sourceCache.read(absolute) ?? (0, fs_1.readFileSync)(absolute, 'utf-8'); } catch { continue; } const lines = content.split('\n'); // --- CC003: hook command that runs raw curl/wget inside settings --- if (/\.claude\/settings(?:\.local)?\.json$/.test(rel) || /\/settings(?:\.local)?\.json$/.test(rel)) { lines.forEach((line, lineIndex) => { const m = /"command"\s*:\s*"([^"]*)"/.exec(line); if (!m) return; const cmd = m[1]; if (/\b(?:curl|wget|nc|scp)\b[^"]*\bhttps?:\/\//i.test(cmd) || /\bcurl\b[^"]*\|\s*(?:sh|bash|zsh)\b/i.test(cmd)) { results.push(this.result('CC003', rel, lineIndex, lines, 0.9, line.trim(), 'Hook command performs an outbound network call — possible exfiltration.')); } }); } // --- CC001: secret literal in .claude/settings*.json -------------- if (/\.claude\/settings(?:\.local)?\.json$/.test(rel) || /\/settings(?:\.local)?\.json$/.test(rel)) { this.findSecretLines(content, lines).forEach(({ lineIndex, match }) => { results.push(this.result('CC001', rel, lineIndex, lines, 0.9, match, 'Literal credential present in a committed Claude Code settings file.')); }); } // --- CC005: MCP server env block with a literal secret ------------ if (/mcp\.json$/.test(rel) || /cline_mcp_settings\.json$/.test(rel)) { this.findMcpEnvSecrets(content, lines).forEach(({ lineIndex, match }) => { results.push(this.result('CC005', rel, lineIndex, lines, 0.9, match, 'MCP server `env` entry contains a literal secret rather than a `${VAR}` reference.')); }); } // --- CC002 / CC003: shell hooks ---------------------------------- if (rel.endsWith('.sh') && /\.claude\//.test(rel)) { lines.forEach((line, lineIndex) => { // Unquoted destructive expansion: rm -rf $VAR, mv $FOO $BAR, eval $CMD if (/\b(rm|mv|cp|eval|dd)\b[^|]*\s\$[A-Za-z_][A-Za-z0-9_]*(?![\w"'])/.test(line) && !/"(?:[^"]*\$[A-Za-z_][A-Za-z0-9_]*[^"]*)+"/.test(line)) { results.push(this.result('CC002', rel, lineIndex, lines, 0.9, line.trim(), 'Destructive command uses `$VAR` without surrounding double quotes.')); } // curl | sh pattern (remote pipe-to-shell) if (/\bcurl\b[^|]*\|\s*(?:sh|bash|zsh)\b/.test(line) || /\bwget\s+-qO-\s+[^|]*\|\s*(?:sh|bash)\b/.test(line)) { results.push(this.result('CC003', rel, lineIndex, lines, 0.95, line.trim(), 'Pipes remote content directly into a shell interpreter.')); } }); } // --- CC004: secret-shaped string in CLAUDE.md / agents/*.md ------- if (/CLAUDE\.md$/.test(rel) || /AGENTS\.md$/.test(rel) || /GEMINI\.md$/.test(rel) || /\.claude\/agents\//.test(rel) || /\.claude\/commands\//.test(rel)) { this.findSecretLines(content, lines).forEach(({ lineIndex, match }) => { results.push(this.result('CC004', rel, lineIndex, lines, 0.9, match, 'Secret-shaped literal inside an agent memory / prompt file.')); }); this.findInjectionMarkers(content, lines).forEach(({ lineIndex, match }) => { results.push(this.result('CC008', rel, lineIndex, lines, 0.8, match, 'Possible prompt-injection directive in agent memory / prompt file.')); }); } // --- CC006: secret-shaped string in cursor/windsurf/aider rules --- if (/\.cursorrules$/.test(rel) || /\.cursor\/rules\//.test(rel) || /\.windsurfrules$/.test(rel) || /\.aiderconfig$/.test(rel) || /\.aider\.conf\.ya?ml$/.test(rel)) { this.findSecretLines(content, lines).forEach(({ lineIndex, match }) => { results.push(this.result('CC006', rel, lineIndex, lines, 0.85, match, 'Secret-shaped literal inside a committed agent rules file.')); }); this.findInjectionMarkers(content, lines).forEach(({ lineIndex, match }) => { results.push(this.result('CC008', rel, lineIndex, lines, 0.8, match, 'Possible prompt-injection directive in agent rules file.')); }); } // --- CC007: session transcripts / todos committed ----------------- if (/\.claude\/todos\//.test(rel) || /\.claude\/history\//.test(rel) || /\.claude\/logs\//.test(rel)) { results.push(this.result('CC007', rel, 0, lines, 0.7, rel, 'Claude Code session state committed to the repo (expected to be .gitignored).')); } // --- CC009: stale Cursor hook event names ------------------------ if (/\.cursor\/hooks\.json$/.test(rel)) { this.findUnknownCursorHookEvents(content, lines).forEach(({ lineIndex, match }) => { const finding = this.result('CC009', rel, lineIndex, lines, 0.85, match, 'The hook event is not in Cursor\'s known hook lifecycle.'); const replacement = this.cursorHookEventReplacement(match); if (replacement) { const line = lines[lineIndex] ?? ''; const startColumn = line.indexOf(`"${match}"`) + 2; if (startColumn > 1) { finding.fixEdits = [{ file: rel, startLine: lineIndex + 1, startColumn, endLine: lineIndex + 1, endColumn: startColumn + match.length, replacement }]; } } results.push(finding); }); } // --- CC010: broad agent autonomy in Codex / Cursor configs ------- if (/\.codex\/.*\.toml$/.test(rel) || /\.cursor\/.*\.json$/.test(rel) || /AGENTS\.md$/.test(rel)) { lines.forEach((line, lineIndex) => { if (/(approval[_-]?policy|ask[_-]?for[_-]?approval)["']?\s*[:=]\s*["']?(never|on-request)["']?/i.test(line) || /(sandbox[_-]?mode)["']?\s*[:=]\s*["']?(danger-full-access|workspace-write)["']?/i.test(line) || /full\s+auto(?:nomy|approval)?/i.test(line)) { results.push(this.result('CC010', rel, lineIndex, lines, 0.75, line.trim(), 'Agent configuration appears to allow broad autonomous side effects.')); } }); } // --- CC011: reusable agent skills / commands with dangerous shell - if (/\.cursor\/commands\//.test(rel) || /\.cursor\/skills\//.test(rel) || /\.agents\/skills\//.test(rel) || /\.claude\/commands\//.test(rel)) { lines.forEach((line, lineIndex) => { if (/\b(?:curl|wget)\b[^|]*\|\s*(?:sh|bash|zsh)\b/i.test(line) || /\brm\s+-rf\s+(?:\/|\$[A-Za-z_]|\.{1,2})/i.test(line) || /\b(?:npm publish|git push --force|gh release create)\b/i.test(line)) { results.push(this.result('CC011', rel, lineIndex, lines, 0.85, line.trim(), 'A reusable agent command contains a dangerous shell pattern.')); } }); } } return results; } // -------------------- helpers -------------------------------------------- result(ruleId, file, lineIndex, lines, confidence, match, confidenceReason) { const meta = rules_1.RULES[ruleId]; return { type: meta.severity === 'high' ? 'error' : 'warning', category: meta.category, message: meta.message, file, line: lineIndex + 1, range: { startLine: lineIndex + 1, startColumn: 1, endLine: lineIndex + 1, endColumn: Math.max(1, (lines[lineIndex] ?? '').length) }, severity: meta.severity, ruleId: meta.id, match: (0, redact_1.redact)(match.trim().slice(0, 200)), confidence, confidenceReason, fix: meta.fix }; } // Secret detection uses the same canonical shapes as SEC00x. secretPatterns = [ /sk-[A-Za-z0-9_-]{20,}/g, /sk-ant-[A-Za-z0-9_-]{20,}/g, /sk-proj-[A-Za-z0-9_-]{20,}/g, /eyJ[A-Za-z0-9._-]{20,}\.[A-Za-z0-9._-]{20,}/g, /AKIA[0-9A-Z]{16}/g, /AIza[0-9A-Za-z_-]{35}/g, /ghp_[A-Za-z0-9]{36}/g, /github_pat_[A-Za-z0-9_]{20,}/g, /xox[baprs]-[A-Za-z0-9-]{10,}/g, /sk_live_[A-Za-z0-9]{16,}/g, /pk_live_[A-Za-z0-9]{16,}/g ]; findSecretLines(content, lines) { const out = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; for (const p of this.secretPatterns) { const re = new RegExp(p.source, p.flags); const m = re.exec(line); if (m) { out.push({ lineIndex: i, match: line }); break; } } } return out; } findMcpEnvSecrets(content, lines) { // Walk until we find an `"env"` property, then flag any secret-shaped // value until the block closes. Naive but robust enough for common MCP // configs. const out = []; let inEnvBlock = false; let depth = 0; let envDepth = 0; const envKeyRegex = /^\s*"env"\s*:\s*\{/; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (!inEnvBlock && envKeyRegex.test(line)) { inEnvBlock = true; envDepth = depth + 1; depth = envDepth; // Fall through so same-line `"env": { "KEY": "..." }` is scanned. } if (inEnvBlock) { for (const ch of line) { if (ch === '{') depth++; else if (ch === '}') depth--; } // Flag secret-shaped value, but skip `${VAR}` placeholders. const valueMatch = /"([A-Z_][A-Z0-9_]*)"\s*:\s*"([^"]+)"/.exec(line); if (valueMatch) { const name = valueMatch[1]; const value = valueMatch[2]; if (/\$\{[^}]+\}/.test(value)) continue; let hit = false; for (const p of this.secretPatterns) { const re = new RegExp(p.source, p.flags); if (re.test(value)) { out.push({ lineIndex: i, match: line }); hit = true; break; } } if (!hit) { // MCP convention: env values should be ${VAR} placeholders, not // literals. A non-placeholder string ≥12 chars in a credential- // shaped key is a leak even if it doesn't match a known prefix. const credentialKey = /(SECRET|TOKEN|KEY|PASSWORD|CREDENTIAL|BEARER)/i.test(name); if (credentialKey && value.length >= 12 && !/^(?:true|false|null|\d+)$/i.test(value)) { out.push({ lineIndex: i, match: line }); } } } if (depth < envDepth) { inEnvBlock = false; } } else { for (const ch of line) { if (ch === '{') depth++; else if (ch === '}') depth--; } } } return out; } injectionMarkers = [ /ignore\s+(?:all\s+)?previous\s+instructions/i, /disregard\s+(?:your|the)\s+system\s+prompt/i, /forget\s+(?:all\s+)?instructions/i, /you\s+are\s+now\s+in\s+developer\s+mode/i, /jailbreak\s+activated/i ]; findInjectionMarkers(content, lines) { const out = []; for (let i = 0; i < lines.length; i++) { for (const p of this.injectionMarkers) { if (p.test(lines[i])) { out.push({ lineIndex: i, match: lines[i] }); break; } } } return out; } findUnknownCursorHookEvents(content, lines) { const known = new Set([ 'sessionStart', 'sessionEnd', 'preToolUse', 'postToolUse', 'postToolUseFailure', 'subagentStart', 'subagentStop', 'beforeShellExecution', 'afterShellExecution', 'beforeMCPExecution', 'afterMCPExecution', 'beforeReadFile', 'afterFileEdit', 'beforeSubmitPrompt', 'preCompact', 'stop', 'afterAgentResponse', 'afterAgentThought', 'beforeTabFileRead', 'afterTabFileEdit', 'workspaceOpen' ]); try { const parsed = JSON.parse(content); const hooks = parsed?.hooks && typeof parsed.hooks === 'object' ? parsed.hooks : {}; const out = []; for (const event of Object.keys(hooks)) { if (known.has(event)) continue; const lineIndex = Math.max(0, lines.findIndex((line) => line.includes(`"${event}"`))); out.push({ lineIndex, match: event }); } return out; } catch { return []; } } cursorHookEventReplacement(event) { const replacements = { afterFileEdt: 'afterFileEdit', afterFileEdited: 'afterFileEdit', beforeShellExec: 'beforeShellExecution', afterShellExec: 'afterShellExecution', beforeMcpExecution: 'beforeMCPExecution', afterMcpExecution: 'afterMCPExecution', beforePromptSubmit: 'beforeSubmitPrompt', preCompaction: 'preCompact' }; return replacements[event]; } } exports.AgentSettingsScanner = AgentSettingsScanner; //# sourceMappingURL=agent-settings-scanner.js.map