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.
332 lines • 16.1 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AIScanner = void 0;
const base_scanner_1 = require("./base-scanner");
const rules_1 = require("../rules");
const redact_1 = require("../utils/redact");
/**
* AIScanner — contextual detections for the AI001–AI008 rule pack.
*
* Pattern-only checks (AI001, AI004) live in the generic SecurityScanner
* pattern runner. This scanner handles the rules that need light AST/JSON
* awareness:
*
* AI002 prompt injection sinks (user input → LLM prompt)
* AI003 system prompt / model config leaked into client bundle
* AI005 MCP server config with hardcoded secret
* AI006 LLM tool/function handler missing auth or allowlist
* AI007 streaming LLM endpoint without auth + rate limit
* AI008 unbounded LLM call (no max_tokens / no input length guard)
*
* Heuristics, not a full type system — every finding ships with a
* `confidenceReason` so the user can decide. Tuned to surface real risk
* without spamming demos and tutorials.
*/
class AIScanner extends base_scanner_1.BaseScanner {
name = 'AI Security Scanner';
// SDK call sites that signal "this is an LLM call".
llmCallRegex = /\b(?:openai|anthropic|google\.generativeAI|GoogleGenerativeAI|cohere|groq|mistral|together|replicate|ollama|generateText|generateObject|streamText|streamObject|chat\.completions\.create|messages\.create|getGenerativeModel|invokeModel|complete|chat)\s*\(/;
// Tool / function-calling shape used by Vercel AI SDK, OpenAI tools,
// Anthropic tools, LangChain.
toolBlockRegex = /\b(?:tools|functions)\s*[:=]\s*[{[]/;
// Markers we treat as proof of "this code path is authenticated".
// The trailing token can be a `(` for function calls or `\b` for identifiers,
// so we keep two alternations rather than a single \b...\b pattern (which
// would miss `await auth();` because `)` and `;` are both non-word chars).
authMarkerRegex = /\b(?:getServerSession|getSession|verifyJWT|requireAuth|requireUser|withAuth|isAuthenticated|currentUser|clerk|supabase\.auth|next-auth|lucia|betterAuth)\b|\bauth\s*\(\s*\)/;
rateLimitRegex = /\b(ratelimit|rateLimit|Ratelimit|@upstash\/ratelimit|express-rate-limit|hono-rate-limiter|slowDown|kv\.incr|throttle)\b/;
// Streaming response shapes.
streamingRegex = /\b(StreamingTextResponse|toDataStreamResponse|toAIStreamResponse|streamText|OpenAIStream|AnthropicStream|GoogleGenerativeAIStream|new\s+ReadableStream|Response\.json\([^)]*stream)/;
async scan(options) {
const results = [];
this.initCache(options, 'ai:1');
for await (const ctx of this.iterateFiles(options, '**/*.{js,jsx,ts,tsx,mjs,cjs,json}', ['node_modules/**', 'dist/**', 'build/**', '.next/**', 'coverage/**', 'examples/**'])) {
if (this.hasFileSuppression(ctx.lines))
continue;
const cached = this.getCached(ctx.file, ctx.contentHash);
if (cached) {
results.push(...cached);
continue;
}
const fileResults = [];
if (ctx.file.endsWith('.json')) {
fileResults.push(...this.detectMcpSecrets(ctx.file, ctx.content, ctx.lines));
}
else {
fileResults.push(...this.detectPromptInjection(ctx.file, ctx.content, ctx.lines));
fileResults.push(...this.detectClientSidePrompt(ctx.file, ctx.content, ctx.lines));
fileResults.push(...this.detectUnsafeTools(ctx.file, ctx.content, ctx.lines));
fileResults.push(...this.detectUnauthedStreaming(ctx.file, ctx.content, ctx.lines));
fileResults.push(...this.detectUnboundedCalls(ctx.file, ctx.content, ctx.lines));
}
this.setCached(ctx.file, ctx.contentHash, fileResults);
results.push(...fileResults);
}
this.saveCache();
return results;
}
// ---- AI002 -------------------------------------------------------------
/**
* Heuristic: a template literal feeding a `system:` / `prompt:` / first
* `messages` entry includes an interpolation that names a request input
* (`req.body`, `params.`, `searchParams`, `formData`, `input`, `query`).
*/
detectPromptInjection(file, content, lines) {
if (!this.llmCallRegex.test(content))
return [];
const meta = (0, rules_1.getRule)('AI002')?.meta;
if (!meta)
return [];
const out = [];
// Look for tagged regions like: system: `... ${anything} ...`
const sinkRegex = /\b(?:system|prompt|content|input)\s*:\s*`([^`]{0,800})`/g;
const userInputRegex = /\$\{[^}]*\b(?:req\.body|req\.query|req\.params|searchParams|formData|userInput|userMessage|request\.json|input|prompt|query|message|body)\b[^}]*\}/;
let m;
while ((m = sinkRegex.exec(content))) {
const block = m[1] ?? '';
if (!userInputRegex.test(block))
continue;
const lineIndex = content.slice(0, m.index).split('\n').length - 1;
if (this.isSuppressed(lines, lineIndex, 'AI002'))
continue;
const lineText = lines[lineIndex] ?? '';
out.push(this.createResult({
type: 'error',
category: 'security',
severity: meta.severity,
ruleId: meta.id,
message: meta.message,
fix: meta.fix,
file,
line: lineIndex + 1,
match: (0, redact_1.redact)(lineText.trim().slice(0, 200)),
confidence: 0.7,
confidenceReason: 'Untrusted request input is interpolated directly into a system/prompt template literal.'
}, lineText));
}
return out;
}
// ---- AI003 -------------------------------------------------------------
detectClientSidePrompt(file, content, lines) {
const meta = (0, rules_1.getRule)('AI003')?.meta;
if (!meta)
return [];
const out = [];
// Case A: env vars whose name screams "system prompt" but live in a
// client-bundled namespace.
const envRegex = /\b(?:NEXT_PUBLIC|VITE|PUBLIC|EXPO_PUBLIC|VUE_APP)_[A-Z0-9_]*(?:SYSTEM_PROMPT|MODEL_PROMPT|PROMPT|LLM_INSTRUCTIONS|AI_PROMPT)\b/;
lines.forEach((line, index) => {
if (this.isSuppressed(lines, index, 'AI003'))
return;
const match = envRegex.exec(line);
if (!match)
return;
out.push(this.createResult({
type: 'warning',
category: 'security',
severity: meta.severity,
ruleId: meta.id,
message: meta.message,
fix: meta.fix,
file,
line: index + 1,
match: (0, redact_1.redact)(match[0]),
confidence: 0.85,
confidenceReason: 'Variable is in a client-exposed env namespace (NEXT_PUBLIC_/VITE_/PUBLIC_/EXPO_PUBLIC_).'
}, line));
});
// Case B: literal `system: '...'` block inside a `'use client'` file.
if (/^['"]use client['"];?\s*$/m.test(content) &&
/\bsystem\s*:\s*['"`][^'"`]{40,}['"`]/.test(content)) {
const idx = lines.findIndex((l) => /\bsystem\s*:\s*['"`]/.test(l));
if (idx >= 0 && !this.isSuppressed(lines, idx, 'AI003')) {
out.push(this.createResult({
type: 'warning',
category: 'security',
severity: meta.severity,
ruleId: meta.id,
message: meta.message,
fix: meta.fix,
file,
line: idx + 1,
match: (0, redact_1.redact)(lines[idx].trim().slice(0, 200)),
confidence: 0.7,
confidenceReason: 'Long system prompt literal lives inside a `use client` component.'
}, lines[idx]));
}
}
return out;
}
// ---- AI005 -------------------------------------------------------------
/**
* Targets MCP config files: `.cursor/mcp.json`, `mcp.json`,
* `claude_desktop_config.json`, `windsurf/mcp.json`.
*/
detectMcpSecrets(file, content, lines) {
const lower = file.toLowerCase();
const isMcpConfig = /(?:^|\/)(?:\.cursor\/mcp\.json|mcp\.json|claude_desktop_config\.json|windsurf\/mcp\.json|cline_mcp_settings\.json)$/.test(lower);
if (!isMcpConfig)
return [];
const meta = (0, rules_1.getRule)('AI005')?.meta;
if (!meta)
return [];
let parsed;
try {
parsed = JSON.parse(content);
}
catch {
return [];
}
const servers = parsed?.mcpServers ?? parsed?.servers ?? {};
if (typeof servers !== 'object' || servers === null)
return [];
const out = [];
const secretValueRegex = /^(?:sk-|sk_|pk_|gh[ps]_|ghu_|github_pat_|xox[bopas]-|AKIA|ASIA|AIza|pcsk_|qdr_|r8_|gsk_|sk-ant-|sk-or-|Bearer\s+|postgres:|postgresql:|mongodb\+?srv?:|mysql:)/;
for (const [name, cfg] of Object.entries(servers)) {
const env = cfg?.env;
if (!env || typeof env !== 'object')
continue;
for (const [key, rawValue] of Object.entries(env)) {
if (typeof rawValue !== 'string')
continue;
// Allow `${env:VAR}` / `$VAR` / placeholder syntax.
if (/^(?:\$\{?env[:.]|\$\{?[A-Z_][A-Z0-9_]*\}?$|<.*>|REPLACE_ME|<your)/.test(rawValue))
continue;
if (rawValue.length < 16)
continue;
const looksLikeSecret = secretValueRegex.test(rawValue) || /[A-Za-z0-9_\-]{32,}/.test(rawValue);
if (!looksLikeSecret)
continue;
const needle = `"${key}"`;
const lineIndex = lines.findIndex((l) => l.includes(needle));
const line = lineIndex >= 0 ? lineIndex + 1 : 1;
out.push(this.createResult({
type: 'error',
category: 'security',
severity: meta.severity,
ruleId: meta.id,
message: `MCP server "${name}" env "${key}" has a hardcoded secret`,
fix: meta.fix,
file,
line,
match: (0, redact_1.redact)(rawValue),
confidence: 0.9,
confidenceReason: 'MCP config env value matches a known secret prefix or has high-entropy length.'
}, lines[line - 1] ?? ''));
}
}
return out;
}
// ---- AI006 -------------------------------------------------------------
detectUnsafeTools(file, content, lines) {
if (!this.toolBlockRegex.test(content))
return [];
if (!this.llmCallRegex.test(content))
return [];
const meta = (0, rules_1.getRule)('AI006')?.meta;
if (!meta)
return [];
if (this.authMarkerRegex.test(content))
return [];
// Only worry about handlers that *do* something dangerous.
const dangerousSink = /\b(?:exec|spawn|writeFile|writeFileSync|unlink|rm\s|fetch\s*\(|axios\.|prisma\.|drizzle|knex|supabase\.from|sendMail|sgMail|stripe\.)/.test(content);
if (!dangerousSink)
return [];
const idx = lines.findIndex((l) => this.toolBlockRegex.test(l));
if (idx < 0 || this.isSuppressed(lines, idx, 'AI006'))
return [];
return [
this.createResult({
type: 'error',
category: 'security',
severity: meta.severity,
ruleId: meta.id,
message: meta.message,
fix: meta.fix,
file,
line: idx + 1,
match: (0, redact_1.redact)(lines[idx].trim().slice(0, 200)),
confidence: 0.6,
confidenceReason: 'File defines LLM tools/functions with side-effecting handlers but no auth marker (getServerSession/auth()/etc.).'
}, lines[idx])
];
}
// ---- AI007 -------------------------------------------------------------
detectUnauthedStreaming(file, content, lines) {
if (!this.streamingRegex.test(content))
return [];
if (!this.llmCallRegex.test(content))
return [];
// Only flag route-style files (Next.js route handlers, Hono/Express
// app routes). Skip libraries.
const isRoute = /\/route\.(?:ts|js|tsx|jsx)$/.test(file) ||
/\/api\//.test(file) ||
/\/app\.(?:ts|js)$/.test(file);
if (!isRoute)
return [];
const meta = (0, rules_1.getRule)('AI007')?.meta;
if (!meta)
return [];
const hasAuth = this.authMarkerRegex.test(content);
const hasRate = this.rateLimitRegex.test(content);
if (hasAuth && hasRate)
return [];
const idx = lines.findIndex((l) => this.streamingRegex.test(l));
if (idx < 0 || this.isSuppressed(lines, idx, 'AI007'))
return [];
const missing = [!hasAuth && 'auth check', !hasRate && 'rate limit'].filter(Boolean).join(' + ');
return [
this.createResult({
type: 'error',
category: 'security',
severity: meta.severity,
ruleId: meta.id,
message: `${meta.message} (missing: ${missing})`,
fix: meta.fix,
file,
line: idx + 1,
match: (0, redact_1.redact)(lines[idx].trim().slice(0, 200)),
confidence: 0.7,
confidenceReason: `Streaming LLM response handler missing ${missing}.`
}, lines[idx])
];
}
// ---- AI008 -------------------------------------------------------------
detectUnboundedCalls(file, content, lines) {
if (!this.llmCallRegex.test(content))
return [];
const meta = (0, rules_1.getRule)('AI008')?.meta;
if (!meta)
return [];
const out = [];
// Look for chat.completions.create(...) / generateText(...) / messages.create(...) calls and inspect the next ~20 lines.
const callRegex = /\b(?:chat\.completions\.create|messages\.create|generateText|generateObject|streamText|streamObject|invokeModel|complete)\s*\(\s*\{/g;
let m;
while ((m = callRegex.exec(content))) {
const startLine = content.slice(0, m.index).split('\n').length - 1;
// Capture up to next 25 lines to detect option keys (avoid full-file regex blowups).
const block = lines.slice(startLine, startLine + 25).join('\n');
const hasMaxTokens = /\b(?:max_tokens|maxOutputTokens|maxTokens|max_output_tokens)\s*:/.test(block);
if (hasMaxTokens)
continue;
if (this.isSuppressed(lines, startLine, 'AI008'))
continue;
out.push(this.createResult({
type: 'warning',
category: 'security',
severity: meta.severity,
ruleId: meta.id,
message: meta.message,
fix: meta.fix,
file,
line: startLine + 1,
match: (0, redact_1.redact)((lines[startLine] ?? '').trim().slice(0, 200)),
confidence: 0.55,
confidenceReason: 'LLM call options object has no max_tokens / maxOutputTokens key within 25 lines.'
}, lines[startLine] ?? ''));
}
return out;
}
}
exports.AIScanner = AIScanner;
//# sourceMappingURL=ai-scanner.js.map