@vibefoundry/vibe-shield 0.0.0-stage → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +571 -3
  3. package/SECURITY.md +110 -0
  4. package/SKILL.md +124 -0
  5. package/dist/bin/vibe-shield.d.ts +59 -0
  6. package/dist/bin/vibe-shield.js +541 -0
  7. package/dist/src/index.d.ts +25 -0
  8. package/dist/src/index.js +108 -0
  9. package/dist/src/init/scaffold.d.ts +122 -0
  10. package/dist/src/init/scaffold.js +573 -0
  11. package/dist/src/mcp/server.d.ts +127 -0
  12. package/dist/src/mcp/server.js +479 -0
  13. package/dist/src/mcp-guard/skill-auditor.d.ts +105 -0
  14. package/dist/src/mcp-guard/skill-auditor.js +1129 -0
  15. package/dist/src/reporter/console-reporter.d.ts +58 -0
  16. package/dist/src/reporter/console-reporter.js +254 -0
  17. package/dist/src/reporter/formatters.d.ts +38 -0
  18. package/dist/src/reporter/formatters.js +262 -0
  19. package/dist/src/reporter/patch-prompt-writer.d.ts +35 -0
  20. package/dist/src/reporter/patch-prompt-writer.js +238 -0
  21. package/dist/src/scanner/context.d.ts +51 -0
  22. package/dist/src/scanner/context.js +568 -0
  23. package/dist/src/scanner/engine.d.ts +130 -0
  24. package/dist/src/scanner/engine.js +722 -0
  25. package/dist/src/scanner/gitignore.d.ts +49 -0
  26. package/dist/src/scanner/gitignore.js +195 -0
  27. package/dist/src/scanner/lexicon.d.ts +56 -0
  28. package/dist/src/scanner/lexicon.js +357 -0
  29. package/dist/src/scanner/rules/ai-vulnerabilities.d.ts +15 -0
  30. package/dist/src/scanner/rules/ai-vulnerabilities.js +859 -0
  31. package/dist/src/scanner/rules/client-exposure.d.ts +18 -0
  32. package/dist/src/scanner/rules/client-exposure.js +410 -0
  33. package/dist/src/scanner/rules/database-exposure.d.ts +15 -0
  34. package/dist/src/scanner/rules/database-exposure.js +626 -0
  35. package/dist/src/scanner/rules/helpers.d.ts +31 -0
  36. package/dist/src/scanner/rules/helpers.js +85 -0
  37. package/dist/src/scanner/rules/index.d.ts +30 -0
  38. package/dist/src/scanner/rules/index.js +95 -0
  39. package/dist/src/scanner/rules/secret-entropy.d.ts +17 -0
  40. package/dist/src/scanner/rules/secret-entropy.js +338 -0
  41. package/dist/src/scanner/rules/shared.d.ts +100 -0
  42. package/dist/src/scanner/rules/shared.js +161 -0
  43. package/dist/src/scanner/rules/unguarded-routes.d.ts +16 -0
  44. package/dist/src/scanner/rules/unguarded-routes.js +526 -0
  45. package/dist/src/scanner/text.d.ts +90 -0
  46. package/dist/src/scanner/text.js +472 -0
  47. package/dist/src/scanner/types.d.ts +340 -0
  48. package/dist/src/scanner/types.js +195 -0
  49. package/dist/src/secrets/patterns.d.ts +63 -0
  50. package/dist/src/secrets/patterns.js +392 -0
  51. package/package.json +94 -4
@@ -0,0 +1,859 @@
1
+ "use strict";
2
+ /**
3
+ * vibe-shield :: ai-vulnerabilities rules
4
+ * ---------------------------------------
5
+ * Two families live here because they share a root cause: an agent that
6
+ * "makes it work" on the happy path and never models an attacker.
7
+ *
8
+ * - LLM-specific: tool calls that reach a shell, model output piped into
9
+ * `eval`, untrusted context promoted to the system prompt, expensive routes
10
+ * with no quota.
11
+ * - Classic web: command injection, webhook signature bypass, XSS, open
12
+ * redirect, SSRF, path traversal, unrestricted uploads, insecure
13
+ * deserialization.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.AI_VULNERABILITY_RULES = void 0;
17
+ const lexicon_1 = require("../lexicon");
18
+ const text_1 = require("../text");
19
+ const helpers_1 = require("./helpers");
20
+ const shared_1 = require("./shared");
21
+ const APP_EXTENSIONS = ['ts', 'tsx', 'js', 'jsx', 'mts', 'mjs', 'cts', 'cjs', 'py', 'rb', 'php', 'go', 'java'];
22
+ /** Argument text of the call whose `(` is the first one at/after `offset`. */
23
+ function callArgs(ctx, offset) {
24
+ const open = ctx.code.indexOf('(', offset);
25
+ if (open === -1)
26
+ return '';
27
+ return (0, text_1.balancedBlock)(ctx.code, open, '(', ')');
28
+ }
29
+ /** Next.js route handlers are already covered by the auth rules; the LLM rules
30
+ * care about whether the endpoint can be called by a stranger at all. */
31
+ const isPublicEndpoint = (ctx) => ctx.flags.isAppRouterRoute || ctx.flags.isPagesApiRoute || ctx.flags.isServerFrameworkFile || ctx.flags.isServerActionModule;
32
+ /* -------------------------------------------------------------------------- */
33
+ /* VSB-AI-001 - OS command injection */
34
+ /* -------------------------------------------------------------------------- */
35
+ const commandInjection = (0, helpers_1.defineRule)({
36
+ id: 'VSB-AI-001',
37
+ slug: 'command-injection',
38
+ title: 'OS command built from untrusted input',
39
+ category: 'ai-vulnerabilities',
40
+ severity: 'critical',
41
+ confidence: 'high',
42
+ cwe: shared_1.CWES.commandInjection,
43
+ description: '`child_process.exec`, `execSync`, `spawn({ shell: true })`, `os.system`, `subprocess(shell=True)` and PHP backticks all hand the string to a shell. If any part of that string comes from a request, a tool argument or model output, the caller can append `; rm -rf /`.',
44
+ insecureExample: "exec(`convert ${req.body.file} -resize 200x200 out.png`)",
45
+ secureExample: "execFile('convert', [req.body.file, '-resize', '200x200', 'out.png'])",
46
+ remediation: [
47
+ 'Replace `exec`/`execSync` with `execFile`/`spawn` and pass arguments as an array — no shell parsing happens.',
48
+ 'Never pass `shell: true`; if you must, quote and allow-list every interpolated value first.',
49
+ 'In Python use `subprocess.run([...], shell=False)`; in PHP `escapeshellarg()` plus an allow-list.',
50
+ 'Validate the value against a strict pattern (`^[a-z0-9_-]{1,64}$`) before it reaches the process.',
51
+ ],
52
+ references: [
53
+ 'https://nodejs.org/api/child_process.html#child_processexeccommand-options-callback',
54
+ 'https://cheatsheetseries.owasp.org/cheatsheets/OS_Command_Injection_Defense_Cheat_Sheet.html',
55
+ 'https://cwe.mitre.org/data/definitions/78.html',
56
+ ],
57
+ extensions: APP_EXTENSIONS,
58
+ skipFlags: [...shared_1.LOGIC_SKIP_FLAGS],
59
+ contentHints: [/\b(?:exec|execSync|spawn|execFile|system|popen|Runtime\.getRuntime|ProcessBuilder|shell_exec|passthru|subprocess)\b/],
60
+ detect(ctx) {
61
+ const hits = [];
62
+ const sinks = [
63
+ { regex: /\bchild_process\.(?:exec|execSync)\s*\(/g, note: '`child_process.exec` runs through a shell.', needsDynamic: true },
64
+ { regex: /(?<![\w.])exec\s*\(\s*`/g, note: '`exec()` with a template literal runs through a shell.', needsDynamic: true },
65
+ { regex: /(?<![\w.])execSync\s*\(\s*`/g, note: '`execSync()` with a template literal runs through a shell.', needsDynamic: true },
66
+ { regex: /\bspawn(?:Sync)?\s*\([^)]{0,400}?\bshell\s*:\s*true/g, note: '`spawn({ shell: true })` reintroduces shell parsing.', needsDynamic: false },
67
+ { regex: /\bBun\s*\.\s*spawn\s*\(/g, note: 'Verify `Bun.spawn` receives an argv array, not a single command string.', needsDynamic: true },
68
+ { regex: /\bos\s*\.\s*(?:system|popen)\s*\(/g, note: '`os.system`/`os.popen` pass the string to `/bin/sh`.', needsDynamic: true },
69
+ { regex: /\bsubprocess\s*\.\s*(?:run|call|check_output|Popen)\s*\([^)]{0,300}?shell\s*=\s*True/g, note: '`subprocess(shell=True)` enables shell metacharacters.', needsDynamic: false },
70
+ { regex: /\bRuntime\s*\.\s*getRuntime\s*\(\s*\)\s*\.\s*exec\s*\(/g, note: '`Runtime.exec` invokes a shell command.', needsDynamic: true },
71
+ { regex: /\bProcessBuilder\s*\(/g, note: '`ProcessBuilder` with a single string uses a shell.', needsDynamic: true },
72
+ { regex: /\b(?:shell_exec|passthru|system|popen|proc_open)\s*\(/g, note: 'A PHP shell function is called.', needsDynamic: true },
73
+ { regex: /`\s*\$[A-Za-z_]/g, note: 'PHP backtick execution with an interpolated variable.', needsDynamic: false },
74
+ ];
75
+ for (const sink of sinks) {
76
+ for (const match of ctx.code.matchAll(sink.regex)) {
77
+ const args = callArgs(ctx, match.index);
78
+ const dynamic = (0, text_1.looksDynamic)(args) || lexicon_1.UNTRUSTED_SOURCE_REGEX.test(args);
79
+ if (sink.needsDynamic && !dynamic)
80
+ continue;
81
+ if (!sink.needsDynamic && sink.regex.source.includes('shell') && !dynamic)
82
+ continue;
83
+ hits.push({
84
+ offset: match.index,
85
+ matched: match[0].slice(0, 160),
86
+ message: sink.note,
87
+ data: { sink: match[0].slice(0, 40) },
88
+ });
89
+ void args;
90
+ }
91
+ }
92
+ return hits;
93
+ },
94
+ });
95
+ /* -------------------------------------------------------------------------- */
96
+ /* VSB-AI-002 - LLM/MCP tool execute reaching a dangerous sink */
97
+ /* -------------------------------------------------------------------------- */
98
+ const toolExecuteSink = (0, helpers_1.defineRule)({
99
+ id: 'VSB-AI-002',
100
+ slug: 'llm-tool-dangerous-execute',
101
+ title: 'LLM/MCP tool `execute` reaches a shell, eval or filesystem sink',
102
+ category: 'ai-vulnerabilities',
103
+ severity: 'critical',
104
+ confidence: 'medium',
105
+ cwe: shared_1.CWES.codeInjection,
106
+ description: 'A `tool({ ... execute })` is a remote-control surface for the model. If `execute` forwards its argument into `exec`, `eval`, `new Function`, `fetch` or `fs.writeFile`, then a prompt injection anywhere upstream becomes remote code execution on your server.',
107
+ insecureExample: 'tool({ description: "run shell", execute: async ({ cmd }) => exec(cmd) })',
108
+ secureExample: 'tool({ execute: async ({ file }) => execFile("wc", ["-l", allowListed(file)]) })',
109
+ remediation: [
110
+ 'Do not expose a general shell to a model. Expose narrow, typed tools with a schema that constrains the argument.',
111
+ 'Allow-list the operations the tool may perform and validate every argument against a strict schema (`z.enum([...])`).',
112
+ 'Require human confirmation for destructive tools, and run them with the least-privilege credentials.',
113
+ 'Log every tool invocation with its arguments so an injected instruction is auditable.',
114
+ ],
115
+ references: [
116
+ 'https://sdk.vercel.ai/docs/ai-sdk-core/tools-and-tool-calling',
117
+ 'https://modelcontextprotocol.io/docs/concepts/tools',
118
+ 'https://owasp.org/www-project-top-10-for-large-language-model-applications/',
119
+ ],
120
+ extensions: APP_EXTENSIONS,
121
+ skipFlags: [...shared_1.LOGIC_SKIP_FLAGS],
122
+ contentHints: [/\bexecute\s*:/, /\bexecute\s*\(/],
123
+ detect(ctx) {
124
+ if (!lexicon_1.LLM_REGEX.test(ctx.code))
125
+ return [];
126
+ const hits = [];
127
+ const sinkRegex = /\b(?:exec|execSync|spawn|execFile|eval|Function|writeFile|writeFileSync|appendFile|unlink|rm|rmdir|readFile|readFileSync|fetch|axios|request)\s*\(/g;
128
+ const dangerous = /(?:exec|execSync|spawn|execFile|eval|Function|writeFile|writeFileSync|appendFile|unlink|rm|rmdir|readFile|readFileSync)/;
129
+ for (const match of ctx.code.matchAll(/\bexecute\s*(?::|\(|=\s*(?:async\s*)?\()/g)) {
130
+ let body = '';
131
+ const start = match.index + match[0].length;
132
+ if (match[0].includes(':')) {
133
+ // `execute: async ({ cmd }) => { ... }` — skip the parameter list, then
134
+ // take the first block that follows the arrow.
135
+ const params = (0, text_1.balancedBlock)(ctx.code, ctx.code.indexOf('(', start), '(', ')');
136
+ const arrow = ctx.code.indexOf('=>', start + params.length);
137
+ const brace = arrow === -1 ? -1 : ctx.code.indexOf('{', arrow);
138
+ body = brace === -1 ? '' : (0, text_1.balancedBlock)(ctx.code, brace, '{', '}');
139
+ }
140
+ else {
141
+ const open = ctx.code.indexOf('(', match.index);
142
+ const params = (0, text_1.balancedBlock)(ctx.code, open, '(', ')');
143
+ const brace = ctx.code.indexOf('{', open + params.length);
144
+ body = brace === -1 ? '' : (0, text_1.balancedBlock)(ctx.code, brace, '{', '}');
145
+ }
146
+ if (!body)
147
+ continue;
148
+ const sinks = new Set();
149
+ for (const sink of body.matchAll(sinkRegex)) {
150
+ if (!dangerous.test(sink[0]))
151
+ continue;
152
+ // Reading a caller-supplied path is also a traversal sink.
153
+ sinks.add((sink[0] ?? '').replace(/\s*\($/, ''));
154
+ }
155
+ if (!sinks.size)
156
+ continue;
157
+ hits.push({
158
+ offset: match.index,
159
+ matched: `execute · ${[...sinks].slice(0, 3).join(', ')}`,
160
+ message: `An LLM-callable tool forwards model-controlled input into ${[...sinks].slice(0, 3).map((s) => `\`${s}\``).join(', ')}.`,
161
+ severity: sinks.has('exec') || sinks.has('eval') || sinks.has('execSync') || sinks.has('Function') ? 'critical' : 'high',
162
+ confidence: 'medium',
163
+ data: { sinks: [...sinks] },
164
+ });
165
+ }
166
+ return hits;
167
+ },
168
+ });
169
+ /* -------------------------------------------------------------------------- */
170
+ /* VSB-AI-003 - model output into a code sink */
171
+ /* -------------------------------------------------------------------------- */
172
+ const llmOutputEval = (0, helpers_1.defineRule)({
173
+ id: 'VSB-AI-003',
174
+ slug: 'llm-output-code-execution',
175
+ title: 'Model output passed to `eval` / `new Function` / `vm`',
176
+ category: 'ai-vulnerabilities',
177
+ severity: 'critical',
178
+ confidence: 'high',
179
+ cwe: shared_1.CWES.codeInjection,
180
+ description: 'Treating generated text as code turns prompt injection into remote code execution. The model is not a trusted compiler: any page, document or tool result it read can contain the payload.',
181
+ insecureExample: 'const result = await openai.chat.completions.create({...});\neval(result.choices[0].message.content)',
182
+ secureExample: 'const plan = PlanSchema.parse(result.choices[0].message.content);\nrunStep(plan.step); // closed set of steps',
183
+ remediation: [
184
+ 'Never evaluate model output. Parse it into a typed structure (zod schema) and act on an allow-listed set of actions.',
185
+ 'If you must execute generated code, use a hardened sandbox (separate process/container, no network, CPU+memory limits) — never `eval`/`new Function` in your own process.',
186
+ 'Keep the model output out of `vm.runInNewContext` with the host realm, and out of `require`/`import` paths.',
187
+ ],
188
+ references: [
189
+ 'https://owasp.org/www-project-top-10-for-large-language-model-applications/',
190
+ 'https://cwe.mitre.org/data/definitions/94.html',
191
+ ],
192
+ extensions: APP_EXTENSIONS,
193
+ skipFlags: [...shared_1.LOGIC_SKIP_FLAGS],
194
+ contentHints: [/\beval\s*\(/, /new\s+Function/, /runInNewContext|runInThisContext|compile\s*\(/],
195
+ detect(ctx) {
196
+ if (!lexicon_1.LLM_REGEX.test(ctx.code))
197
+ return [];
198
+ const hits = [];
199
+ const assignment = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:await\s+)?[\s\S]{0,400}?(?:completion|choices?|content|generateText|generateObject|invoke|messages\.create|chat\.completions)[\s\S]{0,200}?;/g;
200
+ const modelVars = new Set();
201
+ for (const match of ctx.code.matchAll(assignment)) {
202
+ if (match[1])
203
+ modelVars.add(match[1]);
204
+ }
205
+ const sinks = [
206
+ { regex: /\beval\s*\(/g, note: 'Model output reaches `eval()`.' },
207
+ { regex: /\bnew\s+Function\s*\(/g, note: 'Model output reaches `new Function()`.' },
208
+ { regex: /\bvm\s*\.\s*(?:runInNewContext|runInThisContext|runInContext)\s*\(/g, note: 'Model output is executed in a `vm` context.' },
209
+ { regex: /\brequire\s*\(\s*(?:completion|output|response|result|answer)\b/g, note: 'Model output is used as a module path.' },
210
+ ];
211
+ for (const sink of sinks) {
212
+ for (const match of ctx.code.matchAll(sink.regex)) {
213
+ const args = callArgs(ctx, match.index);
214
+ const referencesModel = [...modelVars].some((name) => new RegExp(`\\b${name}\\b`).test(args));
215
+ const inlineModel = /(?:completion|choices?\.\d+\.message\.content|\.content\b|generateText|generateObject)/.test(args);
216
+ if (!referencesModel && !inlineModel)
217
+ continue;
218
+ hits.push({
219
+ offset: match.index,
220
+ matched: match[0].slice(0, 120),
221
+ message: sink.note,
222
+ data: { sink: match[0].slice(0, 40) },
223
+ });
224
+ }
225
+ }
226
+ return hits;
227
+ },
228
+ });
229
+ /* -------------------------------------------------------------------------- */
230
+ /* VSB-AI-004 - prompt injection via untrusted context */
231
+ /* -------------------------------------------------------------------------- */
232
+ const promptInjection = (0, helpers_1.defineRule)({
233
+ id: 'VSB-AI-004',
234
+ slug: 'prompt-injection-untrusted-context',
235
+ title: 'Untrusted content interpolated into the system prompt',
236
+ category: 'ai-vulnerabilities',
237
+ severity: 'high',
238
+ confidence: 'medium',
239
+ cwe: shared_1.CWES.promptInjection,
240
+ description: 'Request bodies, scraped pages, file contents and RAG chunks must never be concatenated into the `system` message. Whatever the attacker wrote there becomes an instruction the model treats as trusted (OWASP LLM01).',
241
+ insecureExample: 'messages: [{ role: "system", content: `You are a support bot. Context: ${req.body.context}` }]',
242
+ secureExample: 'messages: [\n { role: "system", content: SYSTEM_PROMPT },\n { role: "user", content: `<context>${userSupplied}</context>` },\n]',
243
+ remediation: [
244
+ 'Keep the system prompt a constant. Put all variable content in a `user` message wrapped in explicit delimiters (`<context>…</context>`).',
245
+ 'Tell the model that delimited content is data, never instructions, and that it must ignore instruction-like text inside it.',
246
+ 'Validate and truncate the untrusted content; strip control sequences and "ignore previous instructions" style preambles.',
247
+ 'For tool-using agents, require explicit confirmation before side-effecting tools run on model-generated arguments.',
248
+ ],
249
+ references: [
250
+ 'https://genai.owasp.org/llmrisk/llm01-prompt-injection/',
251
+ 'https://simonwillison.net/2023/Apr/14/worst-that-can-happen/',
252
+ 'https://cwe.mitre.org/data/definitions/74.html',
253
+ ],
254
+ extensions: APP_EXTENSIONS,
255
+ skipFlags: [...shared_1.LOGIC_SKIP_FLAGS],
256
+ contentHints: [/system/i],
257
+ detect(ctx) {
258
+ const hits = [];
259
+ const patterns = [
260
+ /role\s*:\s*['"]system['"][\s\S]{0,400}?content\s*:\s*(`[\s\S]{0,600}?`|"(?:[^"\\]|\\.){0,600}")/g,
261
+ /\bsystem\s*:\s*(`[\s\S]{0,600}?`)/g,
262
+ /system_prompt\s*[:=]\s*(?:f?['"`])/g,
263
+ /\bmessages\s*:\s*\[\s*\{\s*role\s*:\s*['"]system['"]/g,
264
+ ];
265
+ for (const regex of patterns) {
266
+ for (const match of ctx.code.matchAll(regex)) {
267
+ const snippet = match[0];
268
+ const interpolated = /\$\{[^}]+\}|['"]\s*\+\s*\w|\+\s*['"]/.test(snippet) || /\.format\(|%\s*\(|f['"]/.test(snippet);
269
+ if (!interpolated)
270
+ continue;
271
+ if (!lexicon_1.UNTRUSTED_SOURCE_REGEX.test(snippet) && !/\b(?:content|chunk|page|document|context|email|html|transcript|scraped|description|input|query|message)\b/.test(snippet))
272
+ continue;
273
+ // A delimiter wrapper is the documented mitigation.
274
+ const after = ctx.code.slice(match.index, match.index + snippet.length + 200);
275
+ if (/<\/?[a-z-]+>|<context>|<data>|<documents?>|--\s*end/i.test(after) && /<\/?[a-z-]+>/.test(after))
276
+ continue;
277
+ hits.push({
278
+ offset: match.index,
279
+ matched: snippet.slice(0, 140),
280
+ message: 'Untrusted content is interpolated into a system message, so it is read as instructions.',
281
+ data: { sink: 'system-prompt' },
282
+ });
283
+ }
284
+ }
285
+ return hits;
286
+ },
287
+ });
288
+ /* -------------------------------------------------------------------------- */
289
+ /* VSB-AI-005/006/007 - webhook signature verification */
290
+ /* -------------------------------------------------------------------------- */
291
+ const stripeWebhookUnverified = (0, helpers_1.defineRule)({
292
+ id: 'VSB-AI-005',
293
+ slug: 'stripe-webhook-unverified',
294
+ title: 'Stripe webhook handled without signature verification',
295
+ category: 'ai-vulnerabilities',
296
+ severity: 'critical',
297
+ confidence: 'high',
298
+ cwe: shared_1.CWES.signatureVerification,
299
+ description: 'Without `stripe.webhooks.constructEvent`, anyone who learns the URL can POST a forged `checkout.session.completed` and be granted a paid plan for free. The raw request body is required — `req.json()` breaks the signature.',
300
+ insecureExample: 'export async function POST(req: Request) {\n const event = await req.json();\n if (event.type === "checkout.session.completed") upgrade(event.data.object);\n}',
301
+ secureExample: 'const event = stripe.webhooks.constructEvent(await req.text(), sig, process.env.STRIPE_WEBHOOK_SECRET!);',
302
+ remediation: [
303
+ 'Read the raw body (`await req.text()` in App Router, `express.raw({ type: "application/json" })` in Express) — never parse it before verifying.',
304
+ 'Call `stripe.webhooks.constructEvent(rawBody, signature, webhookSecret)` inside a try/catch and return 400 when it throws.',
305
+ 'Read the secret from the environment (`STRIPE_WEBHOOK_SECRET`), never from the request.',
306
+ 'Make the handler idempotent: Stripe retries, and a replayed event must not double-apply.',
307
+ ],
308
+ references: [
309
+ 'https://docs.stripe.com/webhooks#verify-manually',
310
+ 'https://cwe.mitre.org/data/definitions/345.html',
311
+ ],
312
+ extensions: APP_EXTENSIONS,
313
+ skipFlags: [...shared_1.LOGIC_SKIP_FLAGS],
314
+ detect(ctx) {
315
+ // The provider name may appear only in the route path (`app/api/webhooks/stripe`).
316
+ if (!/\bstripe\b/i.test(ctx.relativePath) && !/\bstripe\b/i.test(ctx.code))
317
+ return [];
318
+ const isWebhook = /webhook/i.test(ctx.relativePath) || /\bwebhooks?\b/i.test(ctx.code);
319
+ if (!isWebhook)
320
+ return [];
321
+ if (/constructEvent(?:Async)?\s*\(/.test(ctx.code))
322
+ return [];
323
+ if (/(?:supabase|svix|clerk|polar|lemonsqueezy|paddle|github|shopify|slack|twilio|sendgrid)[\w.]*\.(?:verify|constructEvent|webhooks)/i.test(ctx.code))
324
+ return [];
325
+ const offset = Math.max(0, ctx.code.search(/\bPOST\b|export\s+default|function\s+handler/i));
326
+ return [
327
+ (0, helpers_1.fileHit)(ctx.locate(offset).line, {
328
+ matched: 'stripe webhook',
329
+ message: 'Stripe webhook payloads are trusted without `stripe.webhooks.constructEvent()` — forged events are accepted.',
330
+ data: { provider: 'stripe' },
331
+ }),
332
+ ];
333
+ },
334
+ });
335
+ const webhookSecretFromRequest = (0, helpers_1.defineRule)({
336
+ id: 'VSB-AI-006',
337
+ slug: 'webhook-secret-from-request',
338
+ title: 'Webhook signing secret read from the request',
339
+ category: 'ai-vulnerabilities',
340
+ severity: 'high',
341
+ confidence: 'high',
342
+ cwe: shared_1.CWES.signatureVerification,
343
+ description: 'A signature check that compares against a secret taken from the same request proves nothing: the attacker sends both halves. Secrets must come from the environment or a secret manager.',
344
+ insecureExample: "const secret = req.headers['x-webhook-secret'];\nif (body.secret === secret) process(body);",
345
+ secureExample: 'const secret = process.env.WEBHOOK_SECRET!;\nif (!timingSafeEqual(body.sig, hmac(secret, raw))) return badRequest();',
346
+ remediation: [
347
+ 'Read the signing secret from `process.env` (or your secret manager) only.',
348
+ 'Compare signatures with a constant-time function (`crypto.timingSafeEqual`) over the raw body.',
349
+ 'Reject requests with a missing or malformed signature header before doing any work.',
350
+ ],
351
+ references: ['https://cwe.mitre.org/data/definitions/345.html'],
352
+ extensions: APP_EXTENSIONS,
353
+ skipFlags: [...shared_1.LOGIC_SKIP_FLAGS],
354
+ detect(ctx) {
355
+ return (0, helpers_1.regexHits)(ctx.code, /\b(?:const|let|var)\s+(?:webhookSecret|signingSecret|signatureSecret|endpointSecret|whsec)\s*=\s*(?:req|request|c|ctx|event)\s*\.\s*(?:headers|body|query)[^\n;]*/gi, (match) => ({
356
+ matched: match[0].slice(0, 140),
357
+ message: 'The webhook signing secret is read from the incoming request, which makes the signature check meaningless.',
358
+ }));
359
+ },
360
+ });
361
+ const webhookUnverifiedGeneric = (0, helpers_1.defineRule)({
362
+ id: 'VSB-AI-007',
363
+ slug: 'webhook-signature-not-verified',
364
+ title: 'Webhook endpoint does not verify the provider signature',
365
+ category: 'ai-vulnerabilities',
366
+ severity: 'high',
367
+ confidence: 'medium',
368
+ cwe: shared_1.CWES.signatureVerification,
369
+ description: 'GitHub (`x-hub-signature-256`), Slack, Shopify, Clerk and Svix all sign their deliveries. A handler that parses the body and acts on it without an HMAC comparison accepts forged events from anyone.',
370
+ insecureExample: "export async function POST(req) {\n const payload = await req.json();\n await syncUser(payload);\n}",
371
+ secureExample: 'const ok = verifyHmac(await req.text(), sig, process.env.GITHUB_WEBHOOK_SECRET!);\nif (!ok) return new Response(null, { status: 401 });',
372
+ remediation: [
373
+ 'Verify the provider\'s signature header against the raw body before parsing it.',
374
+ 'Use the provider SDK\'s helper where one exists (`@octokit/webhooks`, `@slack/web-api` verifier, `svix`).',
375
+ 'Return 401 for bad signatures and 200 only after the event is durably processed.',
376
+ ],
377
+ references: [
378
+ 'https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries',
379
+ 'https://docs.svix.com/receiving/verifying-payloads/how',
380
+ ],
381
+ extensions: APP_EXTENSIONS,
382
+ skipFlags: [...shared_1.LOGIC_SKIP_FLAGS],
383
+ detect(ctx) {
384
+ const provider = /(?:github|slack|shopify|clerk|svix|linear|discord|twilio|resend|sendgrid|hubspot|salesforce)/i.exec(ctx.relativePath) ??
385
+ /(?:x-hub-signature|x-slack-signature|x-shopify-hmac|svix-signature|stripe-signature|x-signature)/i.exec(ctx.code);
386
+ if (!provider)
387
+ return [];
388
+ if (!/\bwebhook|\/hooks?\//i.test(ctx.relativePath + ctx.code))
389
+ return [];
390
+ if (/(?:verify|constructEvent|timingSafeEqual|createHmac|hmac|SignatureVerification|WebhookVerification)/i.test(ctx.code))
391
+ return [];
392
+ const offset = Math.max(0, ctx.code.search(/export\s+(?:async\s+)?function\s+POST|export\s+default|app\.post|router\.post/i));
393
+ return [
394
+ (0, helpers_1.fileHit)(ctx.locate(offset).line, {
395
+ matched: provider[0],
396
+ message: 'Webhook handler processes the payload without verifying the provider signature.',
397
+ data: { provider: provider[0].toLowerCase() },
398
+ }),
399
+ ];
400
+ },
401
+ });
402
+ /* -------------------------------------------------------------------------- */
403
+ /* VSB-AI-008 - HTML injection / XSS */
404
+ /* -------------------------------------------------------------------------- */
405
+ const htmlInjection = (0, helpers_1.defineRule)({
406
+ id: 'VSB-AI-008',
407
+ slug: 'html-injection',
408
+ title: 'Untrusted content rendered as raw HTML',
409
+ category: 'ai-vulnerabilities',
410
+ severity: 'high',
411
+ confidence: 'high',
412
+ cwe: shared_1.CWES.xss,
413
+ description: '`dangerouslySetInnerHTML`, `v-html`, `{@html}`, `innerHTML`, `document.write` and jQuery `.html()` all skip the framework\'s escaping. Rendering user or model output that way is stored XSS with a step counter.',
414
+ insecureExample: '<div dangerouslySetInnerHTML={{ __html: post.body }} />',
415
+ secureExample: '<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(post.body) }} />',
416
+ remediation: [
417
+ 'Render text as text (`{post.body}`) instead of HTML.',
418
+ 'If HTML is unavoidable, sanitise with DOMPurify before rendering, and keep the sanitiser on the server where possible.',
419
+ 'Add a strict Content-Security-Policy so an injected `<script>` cannot execute.',
420
+ 'Sanitise on write *and* on read — legacy rows may already contain a payload.',
421
+ ],
422
+ references: [
423
+ 'https://react.dev/reference/react-dom/components/common#dangerously-setting-the-inner-html',
424
+ 'https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html',
425
+ 'https://cwe.mitre.org/data/definitions/79.html',
426
+ ],
427
+ extensions: [...APP_EXTENSIONS, 'vue', 'svelte', 'html'],
428
+ skipFlags: [...shared_1.LOGIC_SKIP_FLAGS],
429
+ contentHints: [/dangerouslySetInnerHTML|v-html|innerHTML|insertAdjacentHTML|document\.write|\{@html\}|\.html\s*\(/],
430
+ detect(ctx) {
431
+ const hits = [];
432
+ const sinks = [
433
+ { regex: /\bdangerouslySetInnerHTML\s*=\s*\{\{\s*__html\s*:\s*([\s\S]{0,300}?)\}\s*\}/g, note: '`dangerouslySetInnerHTML` renders unescaped HTML.' },
434
+ { regex: /\bv-html\s*=\s*["']([^"']{1,200})["']/g, note: '`v-html` renders unescaped HTML.' },
435
+ { regex: /\{@html\s+([^}]{1,200})\}/g, note: 'Svelte `{@html}` renders unescaped HTML.' },
436
+ { regex: /\.\s*innerHTML\s*=\s*([^;\n]{1,200})/g, note: 'Assigning to `innerHTML` parses the string as HTML.' },
437
+ { regex: /\binsertAdjacentHTML\s*\(([^;]{1,200})/g, note: '`insertAdjacentHTML` parses the string as HTML.' },
438
+ { regex: /\bdocument\s*\.\s*write(?:ln)?\s*\(([^;]{1,200})/g, note: '`document.write` parses the string as HTML.' },
439
+ { regex: /\$\s*\([^)]{0,80}\)\s*\.\s*html\s*\(\s*([^)]{1,200})/g, note: 'jQuery `.html()` parses the string as HTML.' },
440
+ ];
441
+ for (const sink of sinks) {
442
+ for (const match of ctx.code.matchAll(sink.regex)) {
443
+ const rawValue = match[1] ?? '';
444
+ // `__html: safe` hides the sanitiser one line up, so resolve single-hop
445
+ // aliases and look at the statement that produced them.
446
+ const alias = /^\s*([A-Za-z_$][\w$]*)\s*$/.exec(rawValue);
447
+ let value = rawValue;
448
+ if (alias && alias[1]) {
449
+ const binding = new RegExp(`(?:const|let|var)\\s+${alias[1]}\\s*=\\s*([^;\\n]{0,300})`).exec(ctx.code);
450
+ if (binding && binding[1])
451
+ value = `${rawValue} ${binding[1]}`;
452
+ }
453
+ if (!value.trim())
454
+ continue;
455
+ if (lexicon_1.SANITIZER_REGEX.test(value))
456
+ continue;
457
+ if (/^\s*(?:['"`][^$'"{]*['"`]|\{\s*['"`][^$'"{]*['"`]\s*\})\s*$/.test(value))
458
+ continue;
459
+ hits.push({
460
+ offset: match.index,
461
+ matched: match[0].slice(0, 160),
462
+ message: sink.note,
463
+ data: { sink: match[0].slice(0, 40) },
464
+ });
465
+ }
466
+ }
467
+ return hits;
468
+ },
469
+ });
470
+ /* -------------------------------------------------------------------------- */
471
+ /* VSB-AI-009 - client-side code execution sinks */
472
+ /* -------------------------------------------------------------------------- */
473
+ const clientEval = (0, helpers_1.defineRule)({
474
+ id: 'VSB-AI-009',
475
+ slug: 'dynamic-code-execution',
476
+ title: 'Dynamic code execution in application code',
477
+ category: 'ai-vulnerabilities',
478
+ severity: 'high',
479
+ confidence: 'medium',
480
+ cwe: shared_1.CWES.codeInjection,
481
+ description: '`eval`, `new Function`, string `setTimeout` and `javascript:` URLs execute a string as code. In a browser bundle this is DOM XSS; on the server it is RCE. Almost every use has a safe, boring replacement.',
482
+ insecureExample: 'const value = eval(`(${rule})`);',
483
+ secureExample: 'switch (rule) {\n case "double": return (n) => n * 2;\n}',
484
+ remediation: [
485
+ 'Replace `eval`/`new Function` with a lookup table of real functions.',
486
+ 'Pass a function reference to `setTimeout`/`setInterval`, never a string.',
487
+ 'Validate `href`/`src` values: only `http:`, `https:` and same-origin relative paths; reject `javascript:` and `data:`.',
488
+ 'If dynamic evaluation is a product requirement, run it in a sandboxed worker with no access to cookies or the DOM.',
489
+ ],
490
+ references: [
491
+ 'https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/eval',
492
+ 'https://cwe.mitre.org/data/definitions/95.html',
493
+ ],
494
+ extensions: [...APP_EXTENSIONS, 'html'],
495
+ skipFlags: [...shared_1.LOGIC_SKIP_FLAGS],
496
+ contentHints: [/\beval\s*\(|new\s+Function|setTimeout\s*\(\s*['"`]|javascript:/],
497
+ detect(ctx) {
498
+ const hits = [];
499
+ for (const match of ctx.code.matchAll(/(?<![\w.$])eval\s*\(/g)) {
500
+ const args = callArgs(ctx, match.index);
501
+ if (!(0, text_1.looksDynamic)(args) && !lexicon_1.UNTRUSTED_SOURCE_REGEX.test(args))
502
+ continue;
503
+ // The AI-003 rule already reports model output reaching eval.
504
+ if (lexicon_1.LLM_REGEX.test(ctx.code) && /(?:completion|choices|\.content|generateText)/.test(args))
505
+ continue;
506
+ hits.push({ offset: match.index, matched: 'eval(', message: '`eval()` executes a dynamically built string.', data: { sink: 'eval' } });
507
+ }
508
+ for (const match of ctx.code.matchAll(/\bnew\s+Function\s*\(/g)) {
509
+ hits.push({ offset: match.index, matched: 'new Function(', message: '`new Function()` compiles a string into executable code.', data: { sink: 'Function' } });
510
+ }
511
+ for (const match of ctx.code.matchAll(/\b(?:setTimeout|setInterval)\s*\(\s*(['"`])/g)) {
512
+ hits.push({ offset: match.index, matched: match[0].slice(0, 40), message: 'A string passed to `setTimeout` is evaluated as code.', data: { sink: 'setTimeout-string' } });
513
+ }
514
+ for (const match of ctx.code.matchAll(/\b(?:href|src|action|formAction)\s*[:=]\s*\{?\s*[`'"]\s*javascript:/gi)) {
515
+ hits.push({ offset: match.index, matched: match[0].slice(0, 80), message: 'A `javascript:` URL executes code when clicked.', data: { sink: 'javascript-url' } });
516
+ }
517
+ return hits;
518
+ },
519
+ });
520
+ /* -------------------------------------------------------------------------- */
521
+ /* VSB-AI-010 - open redirect */
522
+ /* -------------------------------------------------------------------------- */
523
+ const openRedirect = (0, helpers_1.defineRule)({
524
+ id: 'VSB-AI-010',
525
+ slug: 'open-redirect',
526
+ title: 'Redirect target taken from the request',
527
+ category: 'ai-vulnerabilities',
528
+ severity: 'medium',
529
+ confidence: 'medium',
530
+ cwe: shared_1.CWES.openRedirect,
531
+ description: 'Passing a request parameter straight into `redirect()`/`res.redirect()` lets an attacker craft `https://yourapp.com/login?next=https://evil.example` — a perfect phishing link on your own domain. It is also the standard first step of an OAuth token-theft chain.',
532
+ insecureExample: 'return redirect(searchParams.get("next") ?? "/");',
533
+ secureExample: 'const next = searchParams.get("next");\nreturn redirect(next?.startsWith("/") && !next.startsWith("//") ? next : "/");',
534
+ remediation: [
535
+ 'Only allow relative paths: reject anything that starts with `//`, `/\\`, or matches `^[a-z]+:` .',
536
+ 'Better: map an allow-list of known destinations (`{ dashboard: "/dashboard" }`).',
537
+ 'For OAuth flows, keep the post-login target server-side in the session instead of the query string.',
538
+ ],
539
+ references: [
540
+ 'https://cheatsheetseries.owasp.org/cheatsheets/Unvalidated_Redirects_and_Forwards_Cheat_Sheet.html',
541
+ 'https://cwe.mitre.org/data/definitions/601.html',
542
+ ],
543
+ extensions: APP_EXTENSIONS,
544
+ skipFlags: [...shared_1.LOGIC_SKIP_FLAGS],
545
+ contentHints: [/redirect/i],
546
+ detect(ctx) {
547
+ const hits = [];
548
+ const patterns = [
549
+ /\bredirect\s*\(\s*(?:searchParams|req|request|ctx|params)[^)]{0,120}\)/g,
550
+ /\b(?:res|response|reply)\s*\.\s*redirect\s*\(\s*(?:req|request|ctx)[^)]{0,120}\)/gi,
551
+ /\b(?:res|response)\s*\.\s*(?:writeHead|status)\s*\([^)]{0,40}\{[^}]{0,80}\bLocation\s*:\s*(?:req|request|params|searchParams)/gi,
552
+ /\bNextResponse\s*\.\s*redirect\s*\(\s*(?:new\s+URL\s*\()?\s*(?:req|request|searchParams|params)/g,
553
+ ];
554
+ for (const regex of patterns) {
555
+ for (const match of ctx.code.matchAll(regex)) {
556
+ // A guard on the same or the previous line neutralises it.
557
+ const window = ctx.code.slice(Math.max(0, match.index - 400), match.index + match[0].length + 200);
558
+ if (/startsWith\s*\(\s*['"]\/['"]|isRelativeUrl|allowedRedirect|ALLOWED_REDIRECT|new URL\([^)]*,\s*process\.env|URL\([^)]*\)\.origin\s*===/.test(window))
559
+ continue;
560
+ hits.push({
561
+ offset: match.index,
562
+ matched: match[0].slice(0, 140),
563
+ message: 'The redirect target comes from the request without an origin check (open redirect).',
564
+ data: { sink: 'redirect' },
565
+ });
566
+ }
567
+ }
568
+ return hits;
569
+ },
570
+ });
571
+ /* -------------------------------------------------------------------------- */
572
+ /* VSB-AI-011 - SSRF */
573
+ /* -------------------------------------------------------------------------- */
574
+ const ssrf = (0, helpers_1.defineRule)({
575
+ id: 'VSB-AI-011',
576
+ slug: 'server-side-request-forgery',
577
+ title: 'Outbound request to a caller-supplied URL (SSRF)',
578
+ category: 'ai-vulnerabilities',
579
+ severity: 'high',
580
+ confidence: 'medium',
581
+ cwe: shared_1.CWES.ssrf,
582
+ description: '`fetch(req.body.url)` reaches the cloud metadata endpoint (`169.254.169.254`), internal admin panels and `file://` paths from inside your network. It is the highest-value bug class in AI features that "fetch a URL and summarise it".',
583
+ insecureExample: 'const page = await fetch(req.body.url);',
584
+ secureExample: 'const url = new URL(input);\nif (!["http:", "https:"].includes(url.protocol) || isPrivateHost(url.hostname)) throw new Error("blocked");',
585
+ remediation: [
586
+ 'Allow-list the hosts/schemes you will fetch, resolve the hostname and reject private, loopback and link-local ranges.',
587
+ 'Block redirects (`redirect: "manual"`) or re-validate the location on each hop.',
588
+ 'Never forward the response body verbatim; strip headers and enforce a size limit.',
589
+ 'Run fetchers in an egress-restricted network where cloud metadata is unreachable.',
590
+ ],
591
+ references: [
592
+ 'https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html',
593
+ 'https://cwe.mitre.org/data/definitions/918.html',
594
+ ],
595
+ extensions: APP_EXTENSIONS,
596
+ skipFlags: [...shared_1.LOGIC_SKIP_FLAGS],
597
+ contentHints: [/\bfetch\s*\(/, /axios|got\(|request\(|urllib|httpx|net\/http/],
598
+ detect(ctx) {
599
+ const hits = [];
600
+ const sinks = [
601
+ /\bfetch\s*\(\s*(?:req|request|ctx|c|event|searchParams|params|query|body)[\w.\[\]'"]*/g,
602
+ /\baxios\s*(?:\.\s*(?:get|post|put|delete|request))?\s*\(\s*(?:req|request|ctx|params|query|body)/g,
603
+ /\b(?:got|superagent|needle|nodeFetch)\s*\(\s*(?:req|request|params|query|body)/g,
604
+ /\brequests\s*\.\s*(?:get|post|put|delete|request)\s*\(\s*(?:request|self\.request|data\[|params\[|body)/g,
605
+ /\burllib\s*\.\s*request\s*\.\s*urlopen\s*\(\s*(?:request|url)/g,
606
+ ];
607
+ for (const regex of sinks) {
608
+ for (const match of ctx.code.matchAll(regex)) {
609
+ const args = callArgs(ctx, match.index);
610
+ const window = ctx.code.slice(Math.max(0, match.index - 500), match.index + args.length + 500);
611
+ if (/allowlist|allowList|ALLOWED_HOSTS|isPrivate(?:Host|Ip|Address)|169\.254|startsWith\s*\(\s*['"]https?:\/\/[a-z0-9.-]+['"]/.test(window))
612
+ continue;
613
+ hits.push({
614
+ offset: match.index,
615
+ matched: match[0].slice(0, 140),
616
+ message: 'An outbound request is made to a URL that comes from the caller — SSRF.',
617
+ data: { sink: 'fetch' },
618
+ });
619
+ }
620
+ }
621
+ return hits;
622
+ },
623
+ });
624
+ /* -------------------------------------------------------------------------- */
625
+ /* VSB-AI-012 - path traversal */
626
+ /* -------------------------------------------------------------------------- */
627
+ const pathTraversal = (0, helpers_1.defineRule)({
628
+ id: 'VSB-AI-012',
629
+ slug: 'path-traversal',
630
+ title: 'Filesystem path built from request data',
631
+ category: 'ai-vulnerabilities',
632
+ severity: 'high',
633
+ confidence: 'high',
634
+ cwe: shared_1.CWES.pathTraversal,
635
+ description: '`path.join(uploadDir, req.query.file)` with `file=../../.env` reads any file the process can see. `path.join` normalises `..` — it does not contain it.',
636
+ insecureExample: 'res.sendFile(path.join(UPLOAD_DIR, req.params.name));',
637
+ secureExample: 'const safe = path.basename(req.params.name);\nif (!/^[\\w.-]+$/.test(safe)) return res.sendStatus(400);\nres.sendFile(path.join(UPLOAD_DIR, safe));',
638
+ remediation: [
639
+ 'Reduce the input to a basename (`path.basename`) and validate it against `^[A-Za-z0-9._-]+$`.',
640
+ 'Resolve the final path and assert it is still inside the intended root: `resolved.startsWith(rootResolved + path.sep)`.',
641
+ 'Prefer opaque ids over filenames: store the object key in the database and look it up server-side.',
642
+ 'Serve user content from object storage rather than the local filesystem.',
643
+ ],
644
+ references: [
645
+ 'https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html',
646
+ 'https://cwe.mitre.org/data/definitions/22.html',
647
+ ],
648
+ extensions: APP_EXTENSIONS,
649
+ skipFlags: [...shared_1.LOGIC_SKIP_FLAGS],
650
+ contentHints: [/readFile|writeFile|sendFile|createReadStream|existsSync|open\s*\(|Path\s*\(/],
651
+ detect(ctx) {
652
+ const hits = [];
653
+ const sinks = [
654
+ /\b(?:readFile|readFileSync|writeFile|writeFileSync|createReadStream|createWriteStream|unlink|unlinkSync|sendFile|download)\s*\(\s*(?:path\s*\.\s*(?:join|resolve)\s*\(|`|\+\s*)?[^)]{0,160}(?:req|request|ctx|params|query|searchParams|body)[\w.\[\]'"]{0,40}/g,
655
+ /\b(?:open|Path)\s*\(\s*(?:request|params|query|body|filename|file_path)[^)]{0,80}/g,
656
+ ];
657
+ for (const regex of sinks) {
658
+ for (const match of ctx.code.matchAll(regex)) {
659
+ const args = callArgs(ctx, match.index);
660
+ if (/basename\s*\(|normalize\s*\(|startsWith\s*\(|resolve\s*\([^)]*\)\s*\.startsWith/.test(args))
661
+ continue;
662
+ hits.push({
663
+ offset: match.index,
664
+ matched: match[0].slice(0, 150),
665
+ message: 'A filesystem path is built from request-controlled data.',
666
+ data: { sink: 'fs' },
667
+ });
668
+ }
669
+ }
670
+ return hits;
671
+ },
672
+ });
673
+ /* -------------------------------------------------------------------------- */
674
+ /* VSB-AI-013 - unrestricted file upload */
675
+ /* -------------------------------------------------------------------------- */
676
+ const unrestrictedUpload = (0, helpers_1.defineRule)({
677
+ id: 'VSB-AI-013',
678
+ slug: 'unrestricted-file-upload',
679
+ title: 'File upload accepted with no type, size or destination limits',
680
+ category: 'ai-vulnerabilities',
681
+ severity: 'medium',
682
+ confidence: 'medium',
683
+ cwe: shared_1.CWES.unrestrictedUpload,
684
+ description: 'A multer/`formData()` handler with no `limits`, no `fileFilter` and a user-controlled destination stores anything anywhere. HTML/SVG uploads become stored XSS, `.js` uploads become hosting abuse, and an unbounded size is a free disk-exhaustion attack.',
685
+ insecureExample: "const upload = multer({ dest: 'uploads/' });\napp.post('/upload', upload.single('file'), handler);",
686
+ secureExample: "const upload = multer({\n dest: 'uploads/',\n limits: { fileSize: 5 * 1024 * 1024, files: 1 },\n fileFilter: (_r, f, cb) => cb(null, /^image\\/(png|jpeg|webp)$/.test(f.mimetype)),\n});",
687
+ remediation: [
688
+ 'Set `limits.fileSize` and `limits.files` on multer (or an equivalent guard on any other parser).',
689
+ 'Validate the MIME type *and* the magic bytes, and store under a generated filename with a fixed extension.',
690
+ 'Serve uploads from a separate origin or with `Content-Disposition: attachment` plus a strict CSP.',
691
+ 'Scan or re-encode images before serving them.',
692
+ ],
693
+ references: [
694
+ 'https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html',
695
+ 'https://cwe.mitre.org/data/definitions/434.html',
696
+ ],
697
+ extensions: APP_EXTENSIONS,
698
+ skipFlags: [...shared_1.LOGIC_SKIP_FLAGS],
699
+ contentHints: [/multer|formidable|formData\(\)|multipart|busboy|formidable/],
700
+ detect(ctx) {
701
+ const hits = [];
702
+ for (const match of ctx.code.matchAll(/\bmulter\s*\(\s*\{([\s\S]{0,300}?)\}\s*\)/g)) {
703
+ const options = match[1] ?? '';
704
+ if (/\blimits\s*:/.test(options) && /fileSize/.test(options))
705
+ continue;
706
+ hits.push({
707
+ offset: match.index,
708
+ matched: match[0].slice(0, 140),
709
+ message: 'Multer is configured without a `limits.fileSize` cap or a `fileFilter`.',
710
+ data: { sink: 'multer' },
711
+ });
712
+ }
713
+ for (const match of ctx.code.matchAll(/\bformidable\s*\(\s*\{([\s\S]{0,300}?)\}\s*\)/g)) {
714
+ const options = match[1] ?? '';
715
+ if (/maxFileSize/.test(options))
716
+ continue;
717
+ hits.push({
718
+ offset: match.index,
719
+ matched: match[0].slice(0, 140),
720
+ message: 'Formidable is configured without `maxFileSize`.',
721
+ data: { sink: 'formidable' },
722
+ });
723
+ }
724
+ for (const match of ctx.code.matchAll(/\b(?:await\s+)?(?:req|request)\s*\.\s*formData\s*\(\s*\)/g)) {
725
+ const window = ctx.code.slice(match.index, match.index + 700);
726
+ if (/MAX_(?:SIZE|BYTES)|maxSize|file\.size\s*>|content-length/i.test(window))
727
+ continue;
728
+ if (!/\.\s*(?:arrayBuffer|stream|file|put|write)/.test(window))
729
+ continue;
730
+ hits.push({
731
+ offset: match.index,
732
+ matched: 'await request.formData()',
733
+ message: 'An uploaded file is persisted with no size or type validation.',
734
+ data: { sink: 'formData' },
735
+ });
736
+ }
737
+ return hits;
738
+ },
739
+ });
740
+ /* -------------------------------------------------------------------------- */
741
+ /* VSB-AI-014 - LLM route without rate limiting */
742
+ /* -------------------------------------------------------------------------- */
743
+ const llmRateLimit = (0, helpers_1.defineRule)({
744
+ id: 'VSB-AI-014',
745
+ slug: 'llm-route-without-rate-limit',
746
+ title: 'LLM endpoint has no rate limit or quota',
747
+ category: 'ai-vulnerabilities',
748
+ severity: 'medium',
749
+ confidence: 'medium',
750
+ cwe: shared_1.CWES.resourceExhaustion,
751
+ description: 'A public route that calls a paid model costs real money per request. Without a per-user quota it is a free API for anyone who finds the URL — and one script can run your bill into the thousands before you notice.',
752
+ insecureExample: 'export async function POST(req) {\n const { messages } = await req.json();\n return streamText({ model, messages });\n}',
753
+ secureExample: 'const { success } = await ratelimit.limit(userId);\nif (!success) return new Response("Too many requests", { status: 429 });',
754
+ remediation: [
755
+ 'Add a per-user (not per-IP) rate limit with `@upstash/ratelimit`, `express-rate-limit` or your platform\'s WAF.',
756
+ 'Set a hard `max_tokens` and a request timeout so a single call cannot run away.',
757
+ 'Require authentication and check a plan/quota before the provider call.',
758
+ 'Alert on spend anomalies at the provider level as a backstop.',
759
+ ],
760
+ references: [
761
+ 'https://owasp.org/www-project-top-10-for-large-language-model-applications/',
762
+ 'https://cwe.mitre.org/data/definitions/770.html',
763
+ ],
764
+ extensions: APP_EXTENSIONS,
765
+ skipFlags: [...shared_1.LOGIC_SKIP_FLAGS],
766
+ appliesTo: isPublicEndpoint,
767
+ contentHints: [/openai|anthropic|generateText|streamText|chat\.completions|messages\.create/i],
768
+ detect(ctx) {
769
+ if (!lexicon_1.LLM_REGEX.test(ctx.code))
770
+ return [];
771
+ if (!/\b(?:completions|generateText|streamText|generateObject|streamObject|messages\.create|invokeModel|chat\.)\b/.test(ctx.code))
772
+ return [];
773
+ if (lexicon_1.RATE_LIMIT_REGEX.test(ctx.code))
774
+ return [];
775
+ if (ctx.project.hasRateLimiting && lexicon_1.RATE_LIMIT_REGEX.test(ctx.content))
776
+ return [];
777
+ const offset = Math.max(0, ctx.code.search(/\b(?:POST|app\.post|export\s+default)\b/));
778
+ return [
779
+ (0, helpers_1.fileHit)(ctx.locate(offset).line, {
780
+ matched: 'LLM endpoint',
781
+ message: 'This endpoint calls a paid model with no rate limit or quota — an open invitation to burn your API budget.',
782
+ data: { sink: 'llm-route' },
783
+ }),
784
+ ];
785
+ },
786
+ });
787
+ /* -------------------------------------------------------------------------- */
788
+ /* VSB-AI-015 - insecure deserialization */
789
+ /* -------------------------------------------------------------------------- */
790
+ const insecureDeserialization = (0, helpers_1.defineRule)({
791
+ id: 'VSB-AI-015',
792
+ slug: 'insecure-deserialization',
793
+ title: 'Untrusted data deserialized with an unsafe loader',
794
+ category: 'ai-vulnerabilities',
795
+ severity: 'high',
796
+ confidence: 'high',
797
+ cwe: shared_1.CWES.deserialization,
798
+ description: '`pickle.loads`, `yaml.load` without `SafeLoader`, PHP `unserialize` and `node-serialize` all construct arbitrary objects from bytes. When those bytes come from a cookie, body or cache they are a straight path to RCE.',
799
+ insecureExample: 'const cfg = yaml.load(req.body.yaml);',
800
+ secureExample: 'const cfg = yaml.load(req.body.yaml, { schema: yaml.JSON_SCHEMA });',
801
+ remediation: [
802
+ 'Use a data-only format: JSON, or `yaml.safeLoad` / `yaml.load(..., { schema: JSON_SCHEMA })`.',
803
+ 'In Python use `yaml.safe_load` and never `pickle` on untrusted bytes (JSON or msgpack instead).',
804
+ 'In PHP use `json_decode` instead of `unserialize`, or pass `["allowed_classes" => false]`.',
805
+ 'If you inherit a serialised format, sign the payload and verify the signature before deserialising.',
806
+ ],
807
+ references: [
808
+ 'https://cheatsheetseries.owasp.org/cheatsheets/Deserialization_Cheat_Sheet.html',
809
+ 'https://cwe.mitre.org/data/definitions/502.html',
810
+ ],
811
+ extensions: [...APP_EXTENSIONS],
812
+ skipFlags: [...shared_1.LOGIC_SKIP_FLAGS],
813
+ contentHints: [/pickle|unserialize|yaml\.|node-serialize|v8\.deserialize|Marshal\.load/],
814
+ detect(ctx) {
815
+ const hits = [];
816
+ const patterns = [
817
+ { regex: /\bpickle\s*\.\s*loads?\s*\(/g, note: '`pickle` executes arbitrary code while unpickling.' },
818
+ { regex: /\byaml\s*\.\s*load\s*\(/g, note: '`yaml.load` without `SafeLoader` can instantiate arbitrary objects.' },
819
+ { regex: /\bMarshal\s*\.\s*load\s*\(/g, note: 'Ruby `Marshal.load` on untrusted data is RCE.' },
820
+ { regex: /\bunserialize\s*\(\s*[^)]{0,120}\)/g, note: 'PHP `unserialize` on request data allows object injection.' },
821
+ { regex: /\bnode-serialize\b|\bserialize\s*\.\s*unserialize\s*\(/g, note: '`node-serialize` evaluates functions embedded in the payload.' },
822
+ { regex: /\bv8\s*\.\s*deserialize\s*\(/g, note: '`v8.deserialize` on untrusted bytes is unsafe.' },
823
+ ];
824
+ for (const pattern of patterns) {
825
+ for (const match of ctx.code.matchAll(pattern.regex)) {
826
+ const args = callArgs(ctx, match.index);
827
+ const tainted = lexicon_1.UNTRUSTED_SOURCE_REGEX.test(args) || !/safe_load|SafeLoader|JSON_SCHEMA|allowed_classes/.test(ctx.code);
828
+ if (!tainted)
829
+ continue;
830
+ if (/safe_load|SafeLoader|JSON_SCHEMA/.test(match[0]))
831
+ continue;
832
+ hits.push({
833
+ offset: match.index,
834
+ matched: match[0].slice(0, 120),
835
+ message: pattern.note,
836
+ data: { sink: match[0].slice(0, 40) },
837
+ });
838
+ }
839
+ }
840
+ return hits;
841
+ },
842
+ });
843
+ exports.AI_VULNERABILITY_RULES = [
844
+ commandInjection,
845
+ toolExecuteSink,
846
+ llmOutputEval,
847
+ promptInjection,
848
+ stripeWebhookUnverified,
849
+ webhookSecretFromRequest,
850
+ webhookUnverifiedGeneric,
851
+ htmlInjection,
852
+ clientEval,
853
+ openRedirect,
854
+ ssrf,
855
+ pathTraversal,
856
+ unrestrictedUpload,
857
+ llmRateLimit,
858
+ insecureDeserialization,
859
+ ];