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
JavaScript
;
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