guardvibe 3.25.0 → 3.27.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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,24 @@ All notable changes to GuardVibe are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.27.0] - 2026-06-25
9
+
10
+ ### Improved — AST engine: multi-hop SQL-injection taint (no rule/tool count change: 450 rules / 39 tools)
11
+ - **Multi-hop bare-variable SQL sinks.** Dataflow analysis now catches the case where a user-tainted SQL string is built into a *variable* and that bare variable is passed to a query sink (`const q = "SELECT ... " + req.body.x; db.sequelize.query(q)`). The inline taint patterns only match the dangerous string when it appears literally in the sink call, so they missed the variable-indirection (multi-hop) shape; the AST locates sinks whose first argument is a bare identifier and confirms it is a tainted SQL string before reporting.
12
+ - **High precision / zero-FP guarding:** reports only when the variable is user-tainted *and* its definition is provably a SQL string (carries SQL keywords) — a parameterized query (`db.query(q, [userVal])`) stays silent (the SQL string has no tainted source; the user value rides the bind array), as does a non-SQL `.query(opts)` or a sanitizer-wrapped service-layer build. Deterministic (bundled TypeScript parser).
13
+ - Corpus delta: 1 real SQL-injection caught that the inline patterns missed, zero false positives, zero drift on other rules. 7 new tests.
14
+
15
+ Gate green (build / lint / test / self-audit PASS / A / 0).
16
+
17
+ ## [3.26.0] - 2026-06-25
18
+
19
+ ### Improved — AST engine: inter-procedural & nested ownership for BOLA/IDOR (no rule/tool count change: 450 rules / 39 tools)
20
+ - **VG950 (find-by-id BOLA) precision via the AST engine.** The ownership guard now also recognizes two real-world authorization shapes the same-function analysis structurally could not see: (1) an ownership field nested inside a relation filter (`members: { some: { userId } }`, `teams.some.team.members.some.userId`), and (2) an **inter-procedural** check — an authorization helper the function calls *before* the query, passing both a session value and the same id (`isAdminForUser(ctx.user.id, targetId)` → throw, then `findUnique({ where: { id: targetId } })`). The same inter-procedural guard now also applies to VG951 (delete/update BOLA).
21
+ - **Soundness preserved:** only a session/auth-derived ownership value counts — a request-controlled value (`req.body.UserId`) is attacker-chosen and keeps firing. Deterministic (bundled TypeScript parser, no resolution of the scanned project's copy).
22
+ - Corpus delta: 3 confirmed false positives removed, zero true positives lost, zero drift on other rules. 8 new tests.
23
+
24
+ Gate green (build / lint / test / self-audit PASS / A / 0).
25
+
8
26
  ## [3.25.0] - 2026-06-24
9
27
 
10
28
  ### Fixed — QA hardening pass (no rule/tool count change: 450 rules / 39 tools)
@@ -26,6 +26,18 @@ export declare function bolaOwnershipGuarded(code: string, filePath: string | un
26
26
  * false on uncertainty so a genuinely unguarded mutation keeps firing.
27
27
  */
28
28
  export declare function bolaMutationGuarded(code: string, filePath: string | undefined, line: number): boolean;
29
+ /**
30
+ * Find SQL-sink calls whose first argument is a BARE identifier (the multi-hop shape
31
+ * the inline regex can't see). Returns the 1-based sink line and the variable name so
32
+ * the taint engine can confirm the variable is a user-tainted SQL string before
33
+ * reporting. Empty (no suppression of other paths) when TypeScript is unavailable or
34
+ * the parse fails. The first argument must be a plain identifier — an inline
35
+ * string/template/concat is already covered by the regex sinks and is skipped here.
36
+ */
37
+ export declare function bareVarSqlSinks(code: string, filePath?: string): Array<{
38
+ line: number;
39
+ varName: string;
40
+ }>;
29
41
  /**
30
42
  * True when the argument to a `new RegExp(...)` at `line` is PROVABLY a constant
31
43
  * (a string literal, a variable assigned from a string literal, or the callback
@@ -150,6 +150,96 @@ const OWNERSHIP_FIELDS = new Set([
150
150
  // A comparison of a fetched resource's ownership field, on a line that also references a session/user.
151
151
  const OWNERSHIP_COMPARE = /\.\s*(?:userId|ownerId|authorId|createdById|teamId|workspaceId|orgId|organizationId|tenantId|memberId|accountId|projectId)\b\s*(?:===|!==|==|!=)/i;
152
152
  const SESSION_REF = /\b(?:session|ctx|auth|currentUser|viewer|member|account|workspace|team|org|self|me|user)\b/i;
153
+ // A value text that is directly request/route-controlled is attacker-chosen, so an
154
+ // ownership field scoped to it (`UserId: req.body.UserId`, `workspaceId: params.x`)
155
+ // is NOT a real guard — the request can name any owner. Only session/auth-derived
156
+ // values count. (Mirrors the existing top-level `params|searchParams` exclusion,
157
+ // extended to req/request so juice-shop's `req.body.UserId` scoping keeps firing.)
158
+ const REQUEST_CONTROLLED = /\b(?:req|request|params|searchParams)\b/;
159
+ /**
160
+ * Recursively scan a `where` object literal for an ownership field (at any nesting
161
+ * depth, e.g. `members: { some: { userId: ... } }`) whose value is session-derived
162
+ * (not request-controlled). The line/regex engine and the prior top-level-only scan
163
+ * miss ownership nested inside relation filters.
164
+ */
165
+ function whereHasNestedOwnership(ts, sf, obj) {
166
+ for (const prop of obj.properties) {
167
+ const nm = prop.name && ts.isIdentifier(prop.name) ? prop.name.text : undefined;
168
+ if (ts.isPropertyAssignment(prop)) {
169
+ if (nm && OWNERSHIP_FIELDS.has(nm)) {
170
+ const valText = prop.initializer.getText(sf);
171
+ if (!REQUEST_CONTROLLED.test(valText))
172
+ return true;
173
+ }
174
+ if (ts.isObjectLiteralExpression(prop.initializer) && whereHasNestedOwnership(ts, sf, prop.initializer))
175
+ return true;
176
+ }
177
+ else if (ts.isShorthandPropertyAssignment(prop) && nm && OWNERSHIP_FIELDS.has(nm)) {
178
+ // `where: { userId }` — the bound variable carries the ownership scope.
179
+ return true;
180
+ }
181
+ }
182
+ return false;
183
+ }
184
+ // An authz/ownership-check helper: an action verb + an authz noun (isAdminForUser,
185
+ // assertOwnership, checkAccess, requirePermission, ensureMemberRole…) or a bare
186
+ // authorize/authorise. Names like formatId/getUserById deliberately do NOT match.
187
+ const AUTHZ_HELPER = /^(?:authoris|authoriz)e|^(?:is|assert|ensure|require|check|verify|can|has|validate|guard|protect|enforce)[A-Za-z]*(?:owner|admin|member|access|permission|auth|allowed|belongs|role)/i;
188
+ /** The text of the `where.id` value (or the call's first-arg id) the call is keyed by. */
189
+ function findKeyedIdText(ts, sf, call) {
190
+ const arg0 = call.arguments[0];
191
+ if (arg0 && ts.isObjectLiteralExpression(arg0)) {
192
+ const whereProp = arg0.properties.find(p => ts.isPropertyAssignment(p) && p.name && ts.isIdentifier(p.name) && p.name.text === "where");
193
+ const whereObj = whereProp && ts.isPropertyAssignment(whereProp) && ts.isObjectLiteralExpression(whereProp.initializer)
194
+ ? whereProp.initializer : arg0;
195
+ const idProp = whereObj.properties.find(p => ts.isPropertyAssignment(p) && p.name && ts.isIdentifier(p.name) && p.name.text === "id");
196
+ if (idProp && ts.isPropertyAssignment(idProp))
197
+ return idProp.initializer.getText(sf);
198
+ }
199
+ return undefined;
200
+ }
201
+ /**
202
+ * Inter-procedural ownership guard: the enclosing function calls an authz-named
203
+ * helper BEFORE the find/mutation, passing both a session value and the same id the
204
+ * query is keyed by (`isAdminForUser(ctx.user.id, input.forUserId)` → throw, then
205
+ * `findUnique({ where: { id: input.forUserId } })`). This is the case VG950/VG951's
206
+ * same-function analysis structurally can't see. Conservative on every axis (authz
207
+ * name + session ref + exact id-sharing + textually-before) so an unrelated guard
208
+ * can't hide a real BOLA.
209
+ */
210
+ function hasInterProceduralOwnershipGuard(ts, sf, target) {
211
+ const idText = findKeyedIdText(ts, sf, target);
212
+ // Require a specific id expression (a member access or a sufficiently long name);
213
+ // a bare `id` is too generic to match a helper argument soundly.
214
+ if (!idText || (!idText.includes(".") && idText.length < 5))
215
+ return false;
216
+ let fn = target;
217
+ while (fn && !(ts.isFunctionDeclaration(fn) || ts.isFunctionExpression(fn) || ts.isArrowFunction(fn) || ts.isMethodDeclaration(fn))) {
218
+ fn = fn.parent;
219
+ }
220
+ if (!fn)
221
+ return false;
222
+ const targetStart = target.getStart(sf);
223
+ let guarded = false;
224
+ const visit = (node) => {
225
+ if (guarded)
226
+ return;
227
+ if (ts.isCallExpression(node) && node !== target && node.getStart(sf) < targetStart) {
228
+ const callee = node.expression;
229
+ const method = ts.isPropertyAccessExpression(callee) ? callee.name.text
230
+ : ts.isIdentifier(callee) ? callee.text : undefined;
231
+ if (method && AUTHZ_HELPER.test(method)) {
232
+ const argsText = node.arguments.map(a => a.getText(sf)).join(", ");
233
+ if (SESSION_REF.test(argsText) && argsText.includes(idText))
234
+ guarded = true;
235
+ }
236
+ }
237
+ if (!guarded)
238
+ ts.forEachChild(node, visit);
239
+ };
240
+ visit(fn);
241
+ return guarded;
242
+ }
153
243
  /** The first CallExpression near `line` whose last-identifier method is in `methods`. */
154
244
  function callNearLine(ts, sf, line, methods) {
155
245
  let target;
@@ -212,23 +302,22 @@ export function bolaOwnershipGuarded(code, filePath, line) {
212
302
  const target = callNearLine(ts, sf, line, FIND_METHODS);
213
303
  if (!target)
214
304
  return false;
215
- // (1) ownership field in the WHERE clause with a non-param value.
305
+ // (1) ownership field in the WHERE clause with a non-param value — now scanned
306
+ // recursively so ownership nested inside a relation filter (`members.some.userId`)
307
+ // counts too, with a session-derived (not request-controlled) value.
216
308
  const arg0 = target.arguments[0];
217
309
  if (arg0 && ts.isObjectLiteralExpression(arg0)) {
218
310
  const whereProp = arg0.properties.find(p => ts.isPropertyAssignment(p) && p.name && ts.isIdentifier(p.name) && p.name.text === "where");
219
- if (whereProp && ts.isPropertyAssignment(whereProp) && ts.isObjectLiteralExpression(whereProp.initializer)) {
220
- for (const prop of whereProp.initializer.properties) {
221
- const nm = prop.name && ts.isIdentifier(prop.name) ? prop.name.text : undefined;
222
- if (nm && OWNERSHIP_FIELDS.has(nm)) {
223
- const valText = ts.isPropertyAssignment(prop) ? prop.initializer.getText(sf) : nm;
224
- if (!/\b(?:params|searchParams)\b/.test(valText))
225
- return true;
226
- }
227
- }
311
+ if (whereProp && ts.isPropertyAssignment(whereProp) && ts.isObjectLiteralExpression(whereProp.initializer)
312
+ && whereHasNestedOwnership(ts, sf, whereProp.initializer)) {
313
+ return true;
228
314
  }
229
315
  }
230
316
  // (2) post-fetch ownership comparison against a session/user value, in the same function.
231
- return hasPostFetchOwnershipGuard(ts, sf, target);
317
+ if (hasPostFetchOwnershipGuard(ts, sf, target))
318
+ return true;
319
+ // (3) inter-procedural: an authz helper checks session + this id before the find.
320
+ return hasInterProceduralOwnershipGuard(ts, sf, target);
232
321
  }
233
322
  /**
234
323
  * BOLA ownership-guard detection for VG951 (delete/update). The rule's regex
@@ -253,7 +342,50 @@ export function bolaMutationGuarded(code, filePath, line) {
253
342
  const target = callNearLine(ts, sf, line, MUTATION_METHODS);
254
343
  if (!target)
255
344
  return false;
256
- return hasPostFetchOwnershipGuard(ts, sf, target);
345
+ // Same-function post-fetch comparison, OR an inter-procedural authz helper that
346
+ // checked session + this id before the mutation (the helper-guard blind spot).
347
+ if (hasPostFetchOwnershipGuard(ts, sf, target))
348
+ return true;
349
+ return hasInterProceduralOwnershipGuard(ts, sf, target);
350
+ }
351
+ // SQL sink methods whose FIRST argument is the query string. The inline taint regex
352
+ // only fires when that string is written literally in the sink call (backtick / `+`),
353
+ // so it misses the case where the SQL string was built into a VARIABLE and the bare
354
+ // variable is passed in (`db.sequelize.query(query)`). `.raw`/`$…Unsafe` are always
355
+ // raw SQL; `query`/`execute` are overloaded, so taint.ts gates them on the variable
356
+ // actually being a user-tainted SQL string.
357
+ const SQL_RAW_SINK_METHODS = new Set(["query", "execute", "raw", "$queryRawUnsafe", "$executeRawUnsafe"]);
358
+ /**
359
+ * Find SQL-sink calls whose first argument is a BARE identifier (the multi-hop shape
360
+ * the inline regex can't see). Returns the 1-based sink line and the variable name so
361
+ * the taint engine can confirm the variable is a user-tainted SQL string before
362
+ * reporting. Empty (no suppression of other paths) when TypeScript is unavailable or
363
+ * the parse fails. The first argument must be a plain identifier — an inline
364
+ * string/template/concat is already covered by the regex sinks and is skipped here.
365
+ */
366
+ export function bareVarSqlSinks(code, filePath) {
367
+ const ts = getTs();
368
+ if (!ts)
369
+ return [];
370
+ let sf;
371
+ try {
372
+ sf = ts.createSourceFile(filePath ?? "file.ts", code, ts.ScriptTarget.Latest, true, scriptKindFor(ts, filePath));
373
+ }
374
+ catch {
375
+ return [];
376
+ }
377
+ const sinks = [];
378
+ const visit = (node) => {
379
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)
380
+ && SQL_RAW_SINK_METHODS.has(node.expression.name.text)
381
+ && node.arguments.length > 0 && ts.isIdentifier(node.arguments[0])) {
382
+ const line = sf.getLineAndCharacterOfPosition(node.arguments[0].getStart(sf)).line + 1;
383
+ sinks.push({ line, varName: node.arguments[0].text });
384
+ }
385
+ ts.forEachChild(node, visit);
386
+ };
387
+ visit(sf);
388
+ return sinks;
257
389
  }
258
390
  const ITER_METHODS = new Set(["map", "forEach", "some", "every", "filter", "find", "findIndex", "reduce", "flatMap"]);
259
391
  /** First `const NAME = <initializer>` for NAME anywhere in the file (file-scope-ish). */
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { isRuleDefinitionFile } from "./check-code.js";
7
7
  import { looksMinified } from "../utils/constants.js";
8
+ import { bareVarSqlSinks } from "./ast-engine.js";
8
9
  // User input sources (tainted data entry points)
9
10
  const TAINT_SOURCES = [
10
11
  { pattern: /(?:req|request)\.(?:body|query|params|headers|cookies)\b/g, type: "http-input" },
@@ -300,6 +301,44 @@ export function analyzeTaint(code, language, filePath) {
300
301
  });
301
302
  }
302
303
  }
304
+ // Multi-hop SQL injection: a user-tainted SQL string built into a VARIABLE and then
305
+ // passed BARE to a SQL sink (`const q = "SELECT ... " + req.body.x; db.query(q)`).
306
+ // The inline sink regexes only match the dangerous string in the sink call itself, so
307
+ // they miss the variable-indirection case. The AST locates sinks whose first argument
308
+ // is a bare identifier; we report only when that identifier is a tainted variable
309
+ // whose definition is provably a SQL string (contains SQL keywords) — high precision,
310
+ // and a parameterized query (`db.query(q, [userVal])`) stays silent because the SQL
311
+ // string `q` has no tainted source and the user value rides the bind array.
312
+ const SQL_KEYWORDS = /\b(?:SELECT|INSERT|UPDATE|DELETE|FROM|WHERE|UNION|DROP|INTO|JOIN)\b/i;
313
+ const hasSqlSinkCandidate = /\.\s*(?:query|execute|raw|\$queryRawUnsafe|\$executeRawUnsafe)\s*\(\s*[A-Za-z_$]/.test(code);
314
+ if (hasSqlSinkCandidate && SQL_KEYWORDS.test(code)) {
315
+ for (const site of bareVarSqlSinks(code, filePath)) {
316
+ const tv = taintedVars.find(v => v.name === site.varName);
317
+ if (!tv)
318
+ continue;
319
+ // The variable must provably hold a SQL string built from user input — its
320
+ // defining assignment line carries SQL keywords (so a non-SQL `.query(opts)` or a
321
+ // bind-parameter value never qualifies).
322
+ const def = lines[tv.line - 1] ?? "";
323
+ if (!SQL_KEYWORDS.test(def))
324
+ continue;
325
+ if (SANITIZERS.some(s => s.test(def)))
326
+ continue;
327
+ if (findings.some(f => f.sink.line === site.line && f.sink.type === "sql-injection"))
328
+ continue;
329
+ findings.push({
330
+ source: { type: tv.sourceType ?? "propagated", line: tv.line, variable: tv.name },
331
+ sink: { type: "sql-injection", line: site.line, code: (lines[site.line - 1] ?? "").trim().substring(0, 100) },
332
+ chain: [
333
+ `[SOURCE] ${tv.sourceType ?? "propagated"} -> ${tv.name} (line ${tv.line})`,
334
+ `[SINK] sql-injection (line ${site.line})`,
335
+ ],
336
+ severity: "critical",
337
+ description: "A user-tainted SQL string is built into a variable and passed to a query sink, enabling SQL injection.",
338
+ fix: "Use parameterized queries with placeholder values (bind parameters); never concatenate user input into the SQL string.",
339
+ });
340
+ }
341
+ }
303
342
  return findings;
304
343
  }
305
344
  export function formatTaintFindings(findings, format) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "guardvibe",
3
- "version": "3.25.0",
3
+ "version": "3.27.0",
4
4
  "mcpName": "io.github.goklab/guardvibe",
5
5
  "description": "Security infrastructure your AI can't be — deterministic, current past your model's training cutoff, whole-repo-aware, author-independent. Security MCP for vibe coding. 450 rules, 39 tools, CLI + doctor. Prompt-level shift-left security (secure_prompt — embed security requirements BEFORE code generation), host security, auth coverage mapping, LLM-powered deep scan (IDOR/business logic), taint analysis. 77 CVE rules refreshed daily from GHSA/OSV/CISA KEV — js-cookie cookie-attribute injection, PostCSS </style> stringify XSS, Axios proxy prototype-pollution gadget, Vite dev-server RCE, React Router 7 cluster, DOMPurify XSS, Better Auth bypass, Miasma @redhat-cloud-services compromise, Next.js May 2026 13-advisory cluster, Drizzle/MikroORM/Kysely SQL injection, Axios proxy-auth redirect leak, Hono setCookie attribute injection, Clerk SSRF, tRPC prototype pollution, @tanstack supply-chain, node-ipc protestware, OpenClaude sandbox bypass, plus the full AI-generated stack (Supabase, Stripe, Prisma, Hono, GraphQL, Convex, Turso, Uploadthing, AI SDK). 68 AI-native rules including OWASP MCP Top 10 tool-description prompt injection (VG1068), model-controlled sandbox-disable flag detection (VG1063), Session messenger exfil endpoint IOC (VG1075), and CI/CD supply-chain hardening (VG1070 npm --expect-provenance / --ignore-scripts enforcement).",
6
6
  "type": "module",